diff --git a/README.md b/README.md
index 6a8270df..15a66041 100644
--- a/README.md
+++ b/README.md
@@ -303,7 +303,8 @@ change:
| If you change… | Update | Which holds |
| --- | --- | --- |
| behaviour, or the reason for it | [docs/design-notes.md](docs/design-notes.md) | the reasoning, the benchmark evidence, superseded behaviour, and what a rule cost before it existed |
-| an endpoint, response field, or run-log event | [docs/API.md](docs/API.md) | what fires a line, what its fields hold, and the remedy — a reference for API consumers, not the rationale |
+| an endpoint, a parameter or a response field | [docs/openapi.yaml](docs/openapi.yaml) | the exact contract, which `/how-it-works` builds its API section from; tests fail if a route or error code is missing |
+| a run-log event | [docs/API.md](docs/API.md) | what fires a line and what its fields hold |
| which model runs an agent | [docs/models.md](docs/models.md) | **suggested** models. Every model is declared in config, never in the code, so no doc may say Iris "uses" one |
| the price of a step | [docs/cost.md](docs/cost.md) | prices with the round each came from. Iris costs nothing per token on self-hosted open-weight models; only a suggested config has a price |
| accessibility reporting, testing, or support information | [ACCESSIBILITY.md](ACCESSIBILITY.md) | reporting and triage guidance, contributor checks, and testing limitations |
diff --git a/public/how-it-works.html b/public/how-it-works.html
index cfbff4a1..60c5d9c1 100644
--- a/public/how-it-works.html
+++ b/public/how-it-works.html
@@ -113,7 +113,7 @@
Everything a caller can give Iris. The settings above are fixed by the deployment.
-
+
+
The API
+
Every endpoint, from GET /v1/openapi.json, the exact contract. Open one to see what it does, try it on real data, and read its answer explained.
+
Loading…
@@ -673,18 +673,6 @@
Share
+ '';
}
- // Every input the API takes. test/how-it-works.test.ts fails if a route reads a request field
- // that is not listed here.
- const INPUTS = [
- { method: 'POST', path: '/sessions', field: 'images', where: 'form upload', what: 'The document: one PDF, or page images in reading order. Required.' },
- { method: 'POST', path: '/sessions/{id}/feedback', field: 'feedback', where: 'JSON body', what: 'What to fix, in words. Iris works on the document again, as a new run.' },
- { method: 'POST', path: '/sessions/{id}/pdf', field: 'values', where: 'JSON body', what: 'Optional form values for the tagged PDF, by field name. Never stored or logged.' },
- { method: 'POST', path: '/sessions/{id}/close', field: null, where: '', what: 'Accept the document. Its pages become examples that future changes to the page agent are checked against.' },
- { method: 'GET', path: '/sessions', field: 'limit', where: 'query', what: 'How many sessions to list at once.' },
- { method: 'GET', path: '/sessions', field: 'status', where: 'query', what: 'List only sessions with this status.' },
- { method: 'GET', path: '/sessions', field: 'cursor', where: 'query', what: 'Where the next page of the list starts.' },
- { method: 'GET', path: '/quality', field: 'days', where: 'query', what: 'How many days the quality tally covers. Needs its own key, and is off unless set.' },
- ];
const modelName = (m) => String(m || '').replace(/^us\./, '');
// The model an agent used in this run (from its log), or the one it is set to use before then.
function modelFor(ev, st, agent, step, cap) {
@@ -832,10 +820,75 @@
Share
+ '
' + (priced ? 'Costs on this page are estimates from the prices above, set by this deployment. They are not a bill.' : 'No prices are set, so this page shows tokens but not cost.') + '
An upload used to accept its own review-round setting. It no longer does: a config field is ignored, and every document gets the deployment\'s settings.
';
+ // ----- The API section, built from the OpenAPI document (docs/openapi.yaml) -----
+ const deref = (spec, o) => (o && o.$ref ? o.$ref.replace(/^#\//, '').split('/').reduce((x, k) => x && x[k], spec) : o);
+ // Endpoints by group, in the document's order, with who may call each.
+ function apiGroups(spec) {
+ const groups = (spec.tags || []).map((t) => ({ tag: t.name, description: t.description || '', ops: [] }));
+ const byTag = new Map(groups.map((g) => [g.tag, g]));
+ for (const [path, ops] of Object.entries(spec.paths || {})) {
+ for (const [method, op] of Object.entries(ops)) {
+ const sec = op.security || [];
+ const auth = sec.some((x) => 'qualityToken' in x) ? 'quality' : sec.some((x) => 'apiKey' in x) ? 'key' : 'open';
+ const params = (op.parameters || []).map((x) => deref(spec, x));
+ const bodySchema = op.requestBody && Object.values(op.requestBody.content || {})[0];
+ const body = bodySchema && bodySchema.schema ? Object.entries(deref(spec, bodySchema.schema).properties || {}).map(([name, sc]) => ({ name, description: sc.description || '', required: (deref(spec, bodySchema.schema).required || []).includes(name) })) : [];
+ const responses = Object.entries(op.responses || {}).map(([code, r]) => ({ code, description: (deref(spec, r) || {}).description || '' }));
+ const g = byTag.get((op.tags || [])[0]) || groups[0];
+ g.ops.push({ method: method.toUpperCase(), path, summary: op.summary || '', description: op.description || '', auth, params, body, responses, op });
+ }
+ }
+ return groups.filter((g) => g.ops.length);
+ }
+ // An answer, field by field: its value beside what the document says the field means.
+ function fieldRows(spec, schema, value, prefix = '', depth = 0, rows = []) {
+ schema = deref(spec, schema) || {};
+ if (rows.length >= 80) return rows;
+ const show = (v) => v === null ? 'null' : Array.isArray(v) ? plural(v.length, 'item') : typeof v === 'object' ? '' : String(v).length > 80 ? String(v).slice(0, 80) + '…' : String(v);
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ for (const [k, v] of Object.entries(value)) {
+ const sub = deref(spec, (schema.properties || {})[k] || (typeof schema.additionalProperties === 'object' ? schema.additionalProperties : {}));
+ rows.push({ path: prefix + k, value: show(v), meaning: (sub && sub.description) || '' });
+ // Inside an array, read its first item against the item schema, not the array's.
+ if (depth < 2 && v && typeof v === 'object') fieldRows(spec, Array.isArray(v) ? deref(spec, sub && sub.items) : sub, Array.isArray(v) ? v[0] : v, prefix + k + (Array.isArray(v) ? '[0]' : '') + '.', depth + 1, rows);
+ }
+ }
+ return rows;
+ }
+ // The document's descriptions mark code with backticks; show it as code, escaped first.
+ const md = (t) => esc(t).replace(/`([^`]+)`/g, '$1');
+ const AUTH_WORDS = { open: 'Open to anyone.', key: 'Needs the API key where this deployment sets one.', quality: 'Needs the quality token.' };
+ function apiCardHtml(o, i) {
+ const k = 'api' + i;
+ const tryable = o.method === 'GET' && o.path !== '/v1/openapi.json';
+ const action = o.method === 'POST' ? (o.path === '/v1/sessions' ? 'Use Start to send one.' : o.path.endsWith('/feedback') ? 'Use the feedback box in step 5 of a finished run.' : o.path.endsWith('/pdf') ? 'Use “Make the tagged PDF” in step 6.' : o.path.endsWith('/close') ? ' Accepting adds its pages to the examples Iris checks future page-agent changes against. It cannot be undone.' : '') : '';
+ return '' + o.method + ' ' + esc(o.path) + ' ' + esc(o.summary) + ''
+ + (o.description ? '
'
+ // The two every gated or limited endpoint shares, said once rather than as a row on each card.
+ + (o.responses.some((r) => r.code === '429') || o.auth === 'key' ? '
' + (o.auth === 'key' ? 'It answers 401 without the key, where one is needed. ' : '') + (o.responses.some((r) => r.code === '429') ? 'It answers 429 when too many requests come from one address.' : '') + '
' : '')
+ + '';
+ }
+ // One Try-it answer: its status and meaning, then the fields explained (or the image, or a size),
+ // then the raw answer for text.
+ function answerHtml(i, a) {
+ const body = a.imageUrl ? ''
+ : a.bytes && !a.rows.length ? '
' + esc(a.bytes) + '
'
+ : a.rows.length ? '
Field
Value
What it means
' + a.rows.map((x) => '
' + esc(x.path) + '
' + esc(x.value) + '
' + md(x.meaning) + '
').join('') + '
'
+ : '
' + esc(a.summary) + '
';
+ return '
' + a.status + ' ' + md(a.description) + '
' + body + (a.raw ? det('raw' + i, 'The answer itself', '
' + esc(a.raw) + '
') : '');
+ }
+ function apiHtml(spec) {
+ if (!spec) return '
This deployment did not serve its API description.
';
+ let i = 0;
+ return apiGroups(spec).map((g) => '
' + esc(g.tag) + '
' + md(g.description) + '
'
+ + (g.tag === 'Operators' ? '
Only for this page and only for these calls. It is never stored, logged or shown.
' : '')
+ + g.ops.map((o) => apiCardHtml(o, i++)).join('')).join('');
}
// ----- State and API calls -----
@@ -853,9 +906,16 @@
Share
if (type && type.includes('html')) return text.length.toLocaleString() + ' characters of HTML.';
try { return JSON.stringify(JSON.parse(text), null, 2).slice(0, 4000); } catch { return text.slice(0, 4000); }
}
- async function call(key, method, path, { body, extra, plain, json = true } = {}) {
- const res = await fetch(API + path, { method, body, headers: body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : undefined });
+ // `headers` carries a token for one call. It is sent and never recorded: the card shows only the answer.
+ async function call(key, method, path, { body, extra, plain, json = true, headers } = {}) {
+ const res = await fetch(API + path, { method, body, headers: { ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}), ...(headers || {}) } });
const type = res.headers.get('content-type') || '';
+ // An image or a PDF is kept as bytes: decoding it as text would fill the card with noise.
+ if (/^image\/|pdf/.test(type)) {
+ const blob = await res.blob();
+ record(key, { method, path, extra, status: res.status, body: blob.size.toLocaleString() + ' bytes of ' + type.split(';')[0] + '.', plain });
+ return { res, text: '', blob, data: null };
+ }
const text = await res.text();
record(key, { method, path, extra, status: res.status, body: summarise(text, type), plain });
return { res, text, data: json && type.includes('json') ? JSON.parse(text) : null };
@@ -865,12 +925,14 @@
Share
try { state.limits = (await call('limits', 'GET', '/limits', { plain: 'What this deployment accepts, and whether it makes tagged PDFs.' })).data; } catch { /* optional */ }
}
- // Before a new upload or a newly opened session: the last one's polling stops, and its cards go.
- // The calls about the deployment itself (/limits, /me, /settings) stay.
+ // Before a new upload or a newly opened session: the last one's polling stops, and its cards go,
+ // with any Try-it answers (they may name the old session). The calls about the deployment itself
+ // (/limits, /me, /settings, /stats, /openapi.json) stay.
function resetSession() {
clearTimeout(state.timer);
Object.assign(state, { id: null, uploaded: false, run: null, events: [], session: null, manifest: null, output: null, diagnostics: null, fields: null, pdf: null, done: false, sourceIsPdf: false });
- for (const k of [...state.calls.keys()]) if (!['limits', 'me', 'settings', 'stats'].includes(k)) state.calls.delete(k);
+ for (const k of [...state.calls.keys()]) if (!['limits', 'me', 'settings', 'stats', 'openapi'].includes(k)) state.calls.delete(k);
+ document.querySelectorAll('.api-answer').forEach((d) => { d.innerHTML = ''; });
lastSteps = null;
snaps.clear();
}
@@ -1198,7 +1260,56 @@
Share
}
} catch { /* unreachable: the first real call will say so */ }
}
- $('inputs-body').innerHTML = inputsHtml();
+ let apiOps = [];
+ call('openapi', 'GET', '/openapi.json', { plain: 'The API\'s exact contract, which the section on every endpoint is built from.' }).then((r) => {
+ state.spec = r.res.ok ? r.data : null;
+ apiOps = state.spec ? apiGroups(state.spec).flatMap((g) => g.ops) : [];
+ $('api-body').innerHTML = apiHtml(state.spec);
+ }).catch(() => { $('api-body').innerHTML = apiHtml(null); });
+ // Fill a path from what is open: the session, and for audit items the first one it has.
+ function tryPath(o) {
+ if (o.path.includes('{id}') && !state.id) return null;
+ const m = state.manifest || { pages: [], files: [], runs: [] };
+ const prompt = state.events.find((e) => e.type === 'model_call_start' && e.prompt);
+ const snap = (m.runs || []).flatMap((r) => r.snapshots.map((x) => [r.run, x.name]))[0];
+ const fill = { id: state.id, order: m.pages[0] && m.pages[0].order, name: o.path.includes('/runs/') ? snap && snap[1] : m.files[0] && m.files[0].name, hash: prompt && prompt.prompt.messages[0].sha256, run: snap && snap[0] };
+ let missing = false;
+ const path = o.path.replace(/\{(\w+)\}/g, (_, k) => { if (fill[k] == null) missing = true; return encodeURIComponent(fill[k]); });
+ return missing ? null : path.replace(/^\/v1/, '') + (o.path === '/v1/sessions' ? '?limit=5' : '');
+ }
+ document.addEventListener('click', async (ev) => {
+ const t = ev.target.closest('button[data-try], button[data-close]');
+ if (!t) return;
+ if (t.dataset.close) {
+ if (!state.id) { setError('Open or convert a document first.'); return; }
+ const r = await call('close', 'POST', '/sessions/' + state.id + '/close', { plain: 'Accepting the document. Its pages become examples for checking future page-agent changes.' });
+ live(r.res.ok ? 'The document was accepted.' : 'It could not be accepted: ' + ((r.data && r.data.error && r.data.error.message) || r.res.status) + '.');
+ return;
+ }
+ const i = Number(t.dataset.try), o = apiOps[i], out = $('api-answer-' + i);
+ const path = tryPath(o);
+ if (!path) { out.innerHTML = '
Open or convert a document first: this needs a session' + (o.path.includes('audit/') ? ' with audit items' : '') + '.
';
+ }
+ t.disabled = false; out.removeAttribute('aria-busy');
+ });
Promise.all([loadLimits(), call('stats', 'GET', '/stats', { plain: 'How much this deployment has converted, and how often a document came back with nothing left open.' }).then((r) => r.res.ok ? r.data : null).catch(() => null), call('settings', 'GET', '/settings', { plain: 'How this deployment is set up: its settings and which model does what. Secrets are never included.' }).then((r) => r.res.ok ? r.data : null).catch(() => null)])
.then(([, stats, st]) => { state.settings = st; $('settings-body').innerHTML = settingsHtml(st, state.limits, stats); safeRender(); });
probe().then(() => {
diff --git a/test/how-it-works.test.ts b/test/how-it-works.test.ts
index fede3b6a..0e81e809 100644
--- a/test/how-it-works.test.ts
+++ b/test/how-it-works.test.ts
@@ -46,22 +46,25 @@ const lib = new Function(
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); const AI = ''; const CODE = '';
const plural = (n, one, many) => n + ' ' + (n === 1 ? one : (many || one + 's'));
${pure}
- return { feedbackFormHtml, firstSummary, changeSummary, stageSettings, modelFor, usageByStage, usageLine, settingsHtml, inputsHtml, processSteps, pdfReportHtml, PDF_WARN, INPUTS, parseLog, parseReply, explain, SAY, runsOf, pagesFrom, roundsFrom, problems, unresolvedFrom, stagesFrom, curl };`,
+ return { answerHtml, md, apiGroups, fieldRows, apiHtml, feedbackFormHtml, firstSummary, changeSummary, stageSettings, modelFor, usageByStage, usageLine, settingsHtml, processSteps, pdfReportHtml, PDF_WARN, parseLog, parseReply, explain, SAY, runsOf, pagesFrom, roundsFrom, problems, unresolvedFrom, stagesFrom, curl };`,
)(index) as {
parseLog(t: string): Record[];
parseReply(o: unknown): Record | null;
explain(e: Record): string;
SAY: Record;
- INPUTS: { method: string; path: string; field: string | null }[];
settingsHtml(st: unknown, limits?: unknown, stats?: unknown): string;
feedbackFormHtml(): string;
+ apiGroups(spec: unknown): { tag: string; ops: { method: string; path: string; auth: string; params: { name: string }[]; body: { name: string }[] }[] }[];
+ fieldRows(spec: unknown, schema: unknown, value: unknown): { path: string; value: string; meaning: string }[];
+ apiHtml(spec: unknown): string;
+ md(t: string): string;
+ answerHtml(i: number, a: unknown): string;
firstSummary(steps: unknown[], id: string, uploaded: boolean): string;
changeSummary(prev: Map, steps: unknown[]): string[];
stageSettings(key: string, ev: unknown[], st: unknown, limits: unknown, session: unknown): string;
modelFor(ev: unknown[], st: unknown, agent: string, step: string, cap: string): string | null;
usageByStage(ev: unknown[], st?: unknown): Record }>;
usageLine(u: unknown, label?: string): string;
- inputsHtml(): string;
processSteps(ev: unknown[], s: unknown, o?: unknown): { key: string; state: string; detail: string; seconds?: number | null }[];
pdfReportHtml(r: unknown): string;
PDF_WARN: Record;
@@ -226,22 +229,26 @@ test("a run's last line that lands after the next run starts still belongs to it
assert.deepEqual(lib.problems(runs[1]), [], "run 2 is still working and has no last line yet");
});
-// "What you can send" is the API's whole input surface: every field a route reads from a request.
-test("every request field a route reads is listed under What you can send", () => {
- const found = new Set();
+// The API section is built from docs/openapi.yaml, so every input a route reads must be in it.
+const spec = (await import("yaml")).parse(readFileSync(join(root, "docs", "openapi.yaml"), "utf8"));
+test("every request field a route reads is an input in the OpenAPI document, in the same place", () => {
+ // Where each read happens: a query string, a JSON body, or a multipart upload part.
+ const found: [string, string][] = [];
for (const f of readdirSync(join(root, "src", "routes")).filter((n) => n.endsWith(".ts"))) {
const src = readFileSync(join(root, "src", "routes", f), "utf8");
- for (const m of src.matchAll(/req\.query\.(\w+)/g)) found.add(m[1]);
- for (const m of src.matchAll(/upload\.(?:array|single)\("(\w+)"/g)) found.add(m[1]);
+ for (const m of src.matchAll(/req\.query\.(\w+)/g)) found.push(["query", m[1]]);
+ for (const m of src.matchAll(/upload\.(?:array|single)\("(\w+)"/g)) found.push(["body", m[1]]);
for (const line of src.split("\n").filter((l) => /req\.body/.test(l))) {
const m = /req\.body[^\n]*?\)\??\.(\w+)/.exec(line);
assert.ok(m, `a req.body read this test cannot parse, in ${f}:\n ${line.trim()}`);
- found.add(m[1]);
+ found.push(["body", m[1]]);
}
}
- assert.ok(found.size >= 7, `only ${found.size} inputs found; the patterns above stopped matching`);
- const listed = new Set(lib.INPUTS.map((i) => i.field).filter(Boolean));
- assert.deepEqual([...found].filter((f) => !listed.has(f)).sort(), [], "a route reads an input the page does not list");
+ const distinct = new Set(found.map(([w, n]) => w + " " + n));
+ assert.ok(distinct.size >= 7, `only ${distinct.size} distinct inputs found; the patterns above stopped matching`);
+ const ops = lib.apiGroups(spec).flatMap((g) => g.ops) as unknown as { params: { name: string; in: string }[]; body: { name: string }[] }[];
+ const listed = new Set(ops.flatMap((o) => [...o.params.filter((p) => p.in === "query").map((p) => "query " + p.name), ...o.body.map((b) => "body " + b.name)]));
+ assert.deepEqual(found.map(([w, n]) => w + " " + n).filter((x) => !listed.has(x)).sort(), [], "a route reads an input the API section does not show where it is read");
});
// axe has no rule for a data table without column headers, so this checks it directly.
@@ -253,9 +260,9 @@ test("the settings and inputs tables all have column headers, and say the settin
providers: { default: "openrouter", openrouter: { default_model: "m-1" } },
defaults: { max_review_iterations: 2, extraction_concurrency: 5, max_concurrent_runs: 2, recheck_sample_size: 1 },
} as never);
- const html = lib.settingsHtml(st, { max_pages: 25 }) + lib.inputsHtml();
+ const html = lib.settingsHtml(st, { max_pages: 25 });
const tables = html.split("
").slice(1);
- assert.ok(tables.length >= 3, "the core, specialist and inputs tables");
+ assert.ok(tables.length >= 2, "the core and specialist tables");
for (const t of tables) assert.match(t.slice(0, t.indexOf("
")), /
/);
for (const words of ["Up to 2 review rounds per document.", "Up to 5 pages read at the same time.", "Up to 25 pages per document."]) assert.ok(html.includes(words), words);
});
@@ -482,3 +489,53 @@ test("while feedback is being routed, the panel says so and nothing else runs ye
assert.deepEqual(steps.map((x) => [x.key, x.state]), [["upload", "working"], ["read", "waiting"], ["check", "waiting"], ["join", "waiting"], ["review", "waiting"], ["result", "waiting"], ["pdf", "skipped"]]);
assert.equal(steps[0].detail, "deciding what to redo");
});
+
+test("the API section shows every operation in the document, grouped, with who may call it", () => {
+ const groups = lib.apiGroups(spec);
+ const shown = groups.flatMap((g) => g.ops.map((o) => o.method + " " + o.path));
+ const inDoc = Object.entries(spec.paths as Record).flatMap(([p, ops]) => Object.keys(ops).map((m) => m.toUpperCase() + " " + p));
+ assert.deepEqual(shown.sort(), inDoc.sort());
+ const op = (m: string, p: string) => groups.flatMap((g) => g.ops).find((o) => o.method === m && o.path === p)!;
+ assert.deepEqual([op("GET", "/v1/stats").auth, op("GET", "/v1/sessions").auth, op("GET", "/v1/quality").auth], ["open", "key", "quality"]);
+ const h = lib.apiHtml(spec);
+ assert.equal((h.match(/[^<]+<\/label>
[^<]*never stored, logged or shown/);
+ assert.ok(!/Bearer (?!YOUR_QUALITY_TOKEN)/.test(h), "a curl never holds a real token");
+});
+
+test("an answer is explained field by field from the document's descriptions", () => {
+ const schema = spec.paths["/v1/stats"].get.responses["200"].content["application/json"].schema;
+ const rows = lib.fieldRows(spec, schema, { pages_processed: 40125, documents_processed: 6723, since: "2026-08-12T00:00:00Z", quality: { window_days: 30, clean_rate: 0.37 } });
+ const by = Object.fromEntries(rows.map((r) => [r.path, r]));
+ assert.equal(by.pages_processed.value, "40125");
+ assert.match(by.pages_processed.meaning, /Pages delivered/);
+ assert.match(by["quality.clean_rate"].meaning, /nothing left open/);
+ assert.equal(lib.apiHtml(null), "
This deployment did not serve its API description.
");
+});
+
+test("backticked code in the document's descriptions shows as code, and nothing else becomes markup", () => {
+ assert.equal(lib.md("`null` on the last page"), "null on the <b>last</b> page");
+});
+
+test("an answer inside an array is explained from the item schema", () => {
+ const schema = spec.paths["/v1/sessions"].get.responses["200"].content["application/json"].schema;
+ const rows = lib.fieldRows(spec, schema, { sessions: [{ session_id: "ses_1", status: "queued" }], next_cursor: null });
+ assert.match(rows.find((r) => r.path === "sessions[0].status")!.meaning, /waits for a free slot/);
+});
+
+test("the API section, rendered, passes the same axe check", async () => {
+ const html = howItWorksPage(page, apiMd).replace('
Loading…
', '
' + lib.apiHtml(spec) + "
");
+ assert.ok(html.includes('data-k="api0"'), "the section was put in the page");
+ // And a Try-it answer in each of its shapes, in the first card's answer area.
+ const rows = [{ path: "status", value: "ok", meaning: "`ok` when up" }];
+ const answers = [
+ lib.answerHtml(0, { status: 200, description: "Up.", rows, raw: '{"status":"ok"}' }),
+ lib.answerHtml(1, { status: 200, description: "The image.", rows: [], imageUrl: "blob:x", bytes: "9 bytes of image/png." }),
+ lib.answerHtml(2, { status: 200, description: "The log.", rows: [], summary: "3 lines of the run log.", raw: "{}" }),
+ ].join("");
+ const withAnswers = html.replace('', '
' + answers + "
");
+ assert.ok(withAnswers.includes("blob:x"), "the answers were put in the page");
+ assert.deepEqual((await runAxe(withAnswers)).violations, []);
+ const r = await runAxe(html);
+ assert.deepEqual(r.violations, []);
+});