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 @@

Before you start

Process

No document yet.

@@ -166,10 +166,10 @@

This deployment

Loading…

-
-

What you can send

-

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.') + '

'); return out.join(''); } - function inputsHtml() { - return '' - + INPUTS.map((i) => '').join('') - + '
CallWhat you sendWhat it does
' + i.method + ' ' + esc(API + i.path) + '' + (i.field ? '' + esc(i.field) + ' (' + esc(i.where) + ')' : 'nothing') + '' + esc(i.what) + '

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 ? '

' + md(o.description) + '

' : '') + + '

' + AUTH_WORDS[o.auth] + '

' + + (o.params.length ? '

You can send

    ' + o.params.map((x) => '
  • ' + esc(x.name) + ' (' + esc(x.in) + ')' + (x.description ? ': ' + md(x.description) : '') + '
  • ').join('') + '
' : '') + + (o.body.length ? '

Body

    ' + o.body.map((x) => '
  • ' + esc(x.name) + '' + (x.required ? ' (required)' : '') + (x.description ? ': ' + md(x.description) : '') + '
  • ').join('') + '
' : '') + + '

Answers

    ' + o.responses.filter((r) => r.code !== '429' && !(r.code === '401' && o.auth === 'key')).map((r) => '
  • ' + esc(r.code) + ' ' + md(r.description) + '
  • ').join('') + '
' + // 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.' : '') + '

' : '') + + '
' + esc(curl(o.method, o.path.replace(/^\/v1/, ''), o.auth === 'quality' ? "-H 'Authorization: Bearer YOUR_QUALITY_TOKEN'" : o.method === 'POST' && o.body.length ? "-H 'Content-Type: application/json' -d '{…}'" : '')) + '
' + + (tryable ? '' : '') + (action ? '

' + action + '

' : '') + + '
'; + } + // 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 ? 'The page image this call returned' + : a.bytes && !a.rows.length ? '

' + esc(a.bytes) + '

' + : a.rows.length ? '' + a.rows.map((x) => '').join('') + '
FieldValueWhat it means
' + esc(x.path) + '' + esc(x.value) + '' + md(x.meaning) + '
' + : '

' + 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' : '') + '.

'; return; } + const token = o.auth === 'quality' ? $('quality-token').value.trim() : ''; + t.disabled = true; out.setAttribute('aria-busy', 'true'); $('calls').hidden = false; + try { + const r = await call('try-' + i, 'GET', path, { json: true, headers: token ? { Authorization: 'Bearer ' + token } : undefined, plain: o.summary + '.' }); + const type = r.res.headers.get('content-type') || ''; + const resp = deref(state.spec, (o.op.responses || {})[String(r.res.status)]) || {}; + const schema = resp.content && resp.content['application/json'] && resp.content['application/json'].schema; + const rows = r.data ? fieldRows(state.spec, schema, r.data) : []; + if (out.dataset.url) URL.revokeObjectURL(out.dataset.url); + const imageUrl = r.blob && type.startsWith('image/') ? URL.createObjectURL(r.blob) : ''; + if (imageUrl) out.dataset.url = imageUrl; + out.innerHTML = answerHtml(i, { status: r.res.status, description: resp.description || '', rows, imageUrl, + bytes: r.blob ? r.blob.size.toLocaleString() + ' bytes of ' + type.split(';')[0] + '.' : '', + summary: type.includes('ndjson') ? r.text.split('\n').filter(Boolean).length + ' lines of the run log.' : type.includes('html') || type.includes('text') ? r.text.length.toLocaleString() + ' characters of text.' : '', + raw: r.blob ? '' : r.text.slice(0, 6000) }); + live('Answer ' + r.res.status + ' from ' + o.method + ' ' + o.path + '.'); + } catch (e) { + out.innerHTML = '

The call failed: ' + esc(e.message) + '

'; + } + 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, []); +});