From a3a1eab69d9aed56c106b01489f9ff30c3fd8d1a Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:02:53 +0200 Subject: [PATCH 1/3] test(diagrams): reproduce comment loss inside SVG CDATA Exercise the public stripper with literal expected SVG strings and retain the observed removal behavior for malformed comment openers. Test-First: docs/diagrams/strip-comments.test.mjs::stripComments preserves literal comment text in CDATA Test-First: docs/diagrams/strip-comments.test.mjs::stripComments retains removal behavior outside CDATA Co-Authored-By: GPT-6.1 Sol --- docs/diagrams/strip-comments.test.mjs | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/diagrams/strip-comments.test.mjs diff --git a/docs/diagrams/strip-comments.test.mjs b/docs/diagrams/strip-comments.test.mjs new file mode 100644 index 00000000..addf1cb2 --- /dev/null +++ b/docs/diagrams/strip-comments.test.mjs @@ -0,0 +1,44 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { stripComments } from './strip-comments.mjs'; + +test('stripComments preserves literal comment text in CDATA', () => { + const cases = [ + [ + 'A <!-- literal --> B', + 'A <!-- literal --> B', + ], + [ + '<!-- unfinished', + '<!-- unfinished', + ], + [ + '<!<!---->-->', + '<!<!---->-->', + ], + [ + '<!-- keep -->', + '<!-- keep -->', + ], + [ + 'left <!-- a --><!-- b --> right', + 'left <!-- a --><!-- b --> right', + ], + ]; + for (const [input, expected] of cases) assert.equal(stripComments(input), expected); +}); + +test('stripComments retains removal behavior outside CDATA', () => { + const cases = [ + ['', ''], + ['', ''], + ['<!-- literal -->', '<!-- literal -->'], + ['-->', '>'], + ['--', ''], + ['', ''], + ['<!-- x -->', ''], + ['aDATA[--x]]>', ''], + ]; + for (const [input, expected] of cases) assert.equal(stripComments(input), expected); +}); From 417eec28c40acde3a83a31059dd96d612dfca354 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:04:26 +0200 Subject: [PATCH 2/3] fix(diagrams): preserve literal SVG comments inside CDATA Protect original CDATA spans before applying the existing fixed-point comment removal to surrounding markup. Ignore CDATA-looking text in real comments, and keep removing openers assembled by earlier removals. Test-First: docs/diagrams/strip-comments.test.mjs::stripComments preserves literal comment text in CDATA Test-First: docs/diagrams/strip-comments.test.mjs::stripComments retains removal behavior outside CDATA Co-Authored-By: GPT-6.1 Sol --- docs/diagrams/strip-comments.mjs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/diagrams/strip-comments.mjs b/docs/diagrams/strip-comments.mjs index 544abd6a..890c3a4c 100644 --- a/docs/diagrams/strip-comments.mjs +++ b/docs/diagrams/strip-comments.mjs @@ -1,8 +1,21 @@ // Separate from export-svg.mjs so tests can import it without Chromium or argv. +export function stripComments(s) { + const parts = []; + let start = 0; + // Match comments too, so CDATA-looking text inside a comment is not protected. + for (const match of s.matchAll(/|)?/g)) { + if (!match[0].startsWith('-->`), so none survives (CodeQL js/incomplete-multi-character-sanitization). -export function stripComments(s) { +function stripMarkupComments(s) { let prev; do { prev = s; s = s.replace(/)?/g, ''); } while (s !== prev); return s; From 1f4ee7a20275c5cf3cb7c8bc7b97f5f05ca513da Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:04:27 +0200 Subject: [PATCH 3/3] test(denkraum): cover CLI stop signal drain budgets Exercise real SIGTERM and SIGHUP delivery plus a SIGBREAK process-event bridge through the public CLI entry. Measure the existing six-second and four-second drain totals while preserving failure output and ledger ownership checks. No-Test: test-only coverage of correct existing stop wiring; no production defect or artificial red phase Co-Authored-By: GPT-6.1 Sol --- tools/denkraum/server-entry-drain.test.mjs | 51 ++++++++++++++-------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/tools/denkraum/server-entry-drain.test.mjs b/tools/denkraum/server-entry-drain.test.mjs index 5f3ec148..abc04dca 100644 --- a/tools/denkraum/server-entry-drain.test.mjs +++ b/tools/denkraum/server-entry-drain.test.mjs @@ -32,14 +32,14 @@ async function freePort() { const { port } = probe.address(); await new Promise(r => probe.close(r)); return port; } // freePort() can race with another binder; retry EADDRINUSE ("konnte nicht starten") on a fresh port, like server-stop/lifecycle. -async function startEntry(t, file, { webhook = false, args = [] } = {}) { +async function startEntry(t, file, { webhook = false, args = [], ipc = false } = {}) { for (let attempt = 1; ; attempt++) { const port = await freePort(); const env = { ...process.env, DECISION_DESK_STATE: file, DECISION_DESK_PORT: String(port), DECISION_DESK_ROOT_AGENT_ID: 'root-test', DECISION_DESK_ROOT_RECEIPT_TOKEN: strongSecret(), DECISION_DESK_WEBHOOK_SECRET: strongSecret() }; delete env.DECISION_DESK_WEBHOOK_URL; if (webhook) env.DECISION_DESK_WEBHOOK_URL = 'https://agentsroom.dev/api/triggers/t_abc123'; - const child = spawn(process.execPath, [...args, entry], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn(process.execPath, [...args, entry], { env, stdio: ['ignore', 'pipe', 'pipe', ...(ipc ? ['ipc'] : [])] }); const out = { stdout: '', stderr: '' }, waiters = []; const feed = (key, d) => { out[key] += d; for (const w of waiters) w(); }; child.stdout.on('data', d => feed('stdout', d)); child.stderr.on('data', d => feed('stderr', d)); @@ -95,20 +95,33 @@ test('DRSEC-G4b: SIGTERM lets an in-flight request finish within the budget and assert.ok(await missing(`${file}.owner`)); }); -test('DRSEC-G4b: SIGTERM with a notification send that never answers prints one short line and exits non-zero and keeps the owner record', { skip: posixOnly }, async t => { - const dir = await workdir(t), file = join(dir, 'ledger.json'), store = new DeskStore(file, { rootAgentId: 'root-test' }); - await store.putQuestion(question('q1')); await store.migrate((await store.read()).revision); - await store.answer({ questionId: 'q1', questionRevision: 1, expectedAnswerId: null, requestId: 'req1', action: 'answer', selected: ['a'], note: 'x' }); - await store.close(); - // The webhook transport is the global fetch; this preload makes it announce itself and never settle. - const preload = join(dir, 'hang-fetch.mjs'); - await writeFile(preload, "globalThis.fetch = () => { process.stdout.write('SEND-STARTED\\n'); return new Promise(() => {}); };\n"); - const run = await startEntry(t, file, { webhook: true, args: ['--import', pathToFileURL(preload).href] }); - assert.equal(await run.output(/SEND-STARTED/), true); - run.child.kill('SIGTERM'); - const result = await within(run.done, 20000); - assert.equal(result.code, 1, result.stderr); - assert.equal(result.stderr.trim().split('\n').length, 1, result.stderr); - assert.match(result.stderr, /^Entscheidungsseite: Beenden fehlgeschlagen: DRAIN_TIMEOUT$/m); - assert.equal(await missing(`${file}.owner`), false, 'a stuck send keeps the owner record (intended)'); -}); +for (const signal of ['SIGTERM', 'SIGHUP', 'SIGBREAK']) { + test(`DRSEC-G4b: ${signal} with a notification send that never answers prints one short line and exits non-zero and keeps the owner record`, { skip: posixOnly }, async t => { + const dir = await workdir(t), file = join(dir, 'ledger.json'), store = new DeskStore(file, { rootAgentId: 'root-test' }); + await store.putQuestion(question('q1')); await store.migrate((await store.read()).revision); + await store.answer({ questionId: 'q1', questionRevision: 1, expectedAnswerId: null, requestId: 'req1', action: 'answer', selected: ['a'], note: 'x' }); + await store.close(); + // The webhook transport is the global fetch; this preload makes it announce itself and never settle. + const preload = join(dir, 'hang-fetch.mjs'); + // POSIX cannot deliver SIGBREAK. Bridge only that OS event through Node's public process emitter; + // this exercises the real CLI listener and stop closure, without claiming Windows delivery coverage. + await writeFile(preload, "globalThis.fetch = () => { process.stdout.write('SEND-STARTED\\n'); return new Promise(() => {}); };\n" + + "process.on('message', signal => { if (signal === 'SIGBREAK') process.emit('SIGBREAK', 'SIGBREAK'); });\n"); + const run = await startEntry(t, file, { webhook: true, args: ['--import', pathToFileURL(preload).href], ipc: signal === 'SIGBREAK' }); + assert.equal(await run.output(/SEND-STARTED/), true); + const started = performance.now(); + if (signal === 'SIGBREAK') run.child.send(signal); + else assert.equal(run.child.kill(signal), true); + const result = await within(run.done, 20000); + const elapsed = performance.now() - started; + t.diagnostic(`${signal} drain: ${elapsed.toFixed(0)} ms`); + assert.equal(result.code, 1, result.stderr); + assert.equal(result.stderr.trim().split('\n').length, 1, result.stderr); + assert.match(result.stderr, /^Entscheidungsseite: Beenden fehlgeschlagen: DRAIN_TIMEOUT$/m); + assert.equal(await missing(`${file}.owner`), false, 'a stuck send keeps the owner record (intended)'); + // The specified totals are 6 s for SIGTERM and 4 s for console-close signals. A 500 ms lower + // tolerance and a 1.5 s upper margin distinguish those budgets without increasing either one. + const [min, max] = signal === 'SIGTERM' ? [5500, 7500] : [3500, 5500]; + assert.ok(elapsed >= min && elapsed < max, `${signal} drain must take ${min}–${max} ms, got ${elapsed} ms`); + }); +}