wave 1 (platform stream): background-observability accuracy + owner-driven follow-ups
- observability: liveness-gated stale-job recovery, extraction run registry on globalThis with explicit 'embedding' phase, /api/jobs force-dynamic, client refresh nudges at every seam - headless: providerSafePng canonicalizer (multi-IDAT Chrome PNGs → single-IDAT) fixing qwen 400s - composer: Enter routes through pure enterAction — accepts a pending plan verdict (owner decision) - markdown chrome: session-scoped mermaid overlay + md-blocks component set + pan-zoom math, corner-copy (clipboard grant in electron main), math/table/code block fixes, heading weight 900 543/543 tests; typecheck/lint/build clean
This commit is contained in:
@@ -457,6 +457,49 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
assert.equal(extractionStatuses().length, 0, "the registry clears when the run ends");
|
||||
});
|
||||
|
||||
it("labels the post-batch embedding tail as its own phase — not 'merging' (2026-09-19 accuracy fix)", async () => {
|
||||
const { chatId } = fixtureChat();
|
||||
const ledgers = memoryLedgers();
|
||||
// Gate the embedder so we can observe the registry WHILE the tail
|
||||
// runs — mergeFindings has already returned, every batch is merged.
|
||||
let releaseEmbed!: () => void;
|
||||
const embedGate = new Promise<void>((resolve) => {
|
||||
releaseEmbed = resolve;
|
||||
});
|
||||
let observed: string | undefined;
|
||||
const gatedEmbedder = async (store: GraphStore) => {
|
||||
await embedGate;
|
||||
observed = extractionStatuses().find((r) => r.chatId === chatId)?.phase;
|
||||
await fakeEmbedder(store);
|
||||
};
|
||||
const verdict: AuditResult = { propositions: FINDINGS.propositions, edges: [], attribution: { quiz: {} }, dropped: [] };
|
||||
const pipeline = createExtractionPipeline({
|
||||
chatStore,
|
||||
graphStore: ledgers,
|
||||
embedder: gatedEmbedder,
|
||||
extractor: submitModel("submit_findings", FINDINGS),
|
||||
auditor: submitModel("submit_review", verdict),
|
||||
labeler: submitModel("submit_labels", { labels: [] }),
|
||||
});
|
||||
const run = pipeline.runExtraction(chatId);
|
||||
// Wait for the tail: the phase must flip to "embedding" while the
|
||||
// embedder hangs — the incident this fixes is minutes of "merging"
|
||||
// AFTER the last merge, so the flip itself is the assertion.
|
||||
const t0 = Date.now();
|
||||
while (extractionStatuses().find((r) => r.chatId === chatId)?.phase !== "embedding") {
|
||||
if (Date.now() - t0 > 2000) {
|
||||
releaseEmbed();
|
||||
await run;
|
||||
assert.fail("the tail never flipped to embedding (stuck at 'merging'?)");
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
releaseEmbed();
|
||||
await run;
|
||||
assert.equal(observed, "embedding", "still embedding through the tail, never back to 'merging'");
|
||||
assert.equal(extractionStatuses().length, 0, "clears when done");
|
||||
});
|
||||
|
||||
it("captures per-stage usage live, persists the run's usage into the chat record", async () => {
|
||||
const { chatId } = fixtureChat();
|
||||
const ledgers = memoryLedgers();
|
||||
@@ -587,6 +630,61 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
await assert.rejects(first, /the extractor never submitted findings/);
|
||||
});
|
||||
|
||||
it("registers runs on the process-wide registry (route-instance safety): a second pipeline instance sees the same single-flight state", async () => {
|
||||
const { chatId } = fixtureChat();
|
||||
const ledgers = memoryLedgers();
|
||||
|
||||
// Gated extractor: the first call hangs until released, so the run
|
||||
// stays registered while we observe it.
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let calls = 0;
|
||||
const gatedExtractor = fauxEngine([
|
||||
async () => {
|
||||
calls++;
|
||||
if (calls === 1) await gate;
|
||||
return fauxAssistantMessage("…");
|
||||
},
|
||||
]);
|
||||
const pipeline = createExtractionPipeline({
|
||||
chatStore,
|
||||
graphStore: ledgers,
|
||||
embedder: fakeEmbedder,
|
||||
extractor: gatedExtractor,
|
||||
auditor: silentModel(),
|
||||
labeler: submitModel("submit_labels", { labels: [] }),
|
||||
});
|
||||
// A SECOND instance over DIFFERENT ledgers — the observer a separate
|
||||
// route entry's module instance is in dev (the turn-liveness lesson:
|
||||
// module instances split; globalThis does not).
|
||||
const observer = createExtractionPipeline({
|
||||
chatStore,
|
||||
graphStore: memoryLedgers(),
|
||||
embedder: fakeEmbedder,
|
||||
extractor: silentModel(),
|
||||
auditor: silentModel(),
|
||||
labeler: submitModel("submit_labels", { labels: [] }),
|
||||
});
|
||||
|
||||
const first = pipeline.runExtraction(chatId);
|
||||
// The synchronous prefix (setPhase at batch start) registers BEFORE
|
||||
// the first await — the run is visible the moment it starts.
|
||||
assert.ok(
|
||||
extractionStatuses().some((run) => run.chatId === chatId && run.phase === "extracting"),
|
||||
"the run is registered at start",
|
||||
);
|
||||
const g = globalThis as { __lhExtractionRuns?: { running: Map<string, unknown> } };
|
||||
assert.ok(g.__lhExtractionRuns?.running.has(chatId), "the registry lives on globalThis — no module-instance split can hide runs");
|
||||
assert.deepEqual(await observer.runExtraction(chatId), { nodes: 0, edges: 0, events: 0 }, "single-flight spans pipeline instances");
|
||||
|
||||
release();
|
||||
await assert.rejects(first, /the extractor never submitted findings/);
|
||||
assert.ok(!extractionStatuses().some((run) => run.chatId === chatId), "the registry entry clears when the run ends (however it ends)");
|
||||
assert.ok(!g.__lhExtractionRuns?.running.has(chatId), "nothing leaks into the global registry");
|
||||
});
|
||||
|
||||
it("throws for unknown chats", async () => {
|
||||
const ledgers = memoryLedgers();
|
||||
const pipeline = createExtractionPipeline({
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import * as zlib from "node:zlib";
|
||||
import { providerSafePng } from "../src/agent/subagents/headless.ts";
|
||||
|
||||
/**
|
||||
* The 2026-09-19 visual-maker fix (the "image format is illegal" 400s):
|
||||
* Chrome writes PNGs with a SPLIT IDAT stream (4KB chunks); Alibaba's vision
|
||||
* decoder — behind OpenRouter's qwen3.8-flash, the visual-maker's look-model —
|
||||
* rejected those during the 2026-09-18/19 window while every single-IDAT form
|
||||
* passed. providerSafePng merges the split back into the canonical ONE-IDAT
|
||||
* form (PNG spec: the IDAT chunks are an arbitrary split of ONE zlib stream —
|
||||
* the merged form carries the byte-identical stream). Pure Node on purpose:
|
||||
* the process already loads transformers' nested sharp/libvips; a second copy
|
||||
* (root sharp) triggered macOS's duplicate-libvips ObjC warning. These tests
|
||||
* pin the property with no native deps at all.
|
||||
*/
|
||||
|
||||
// ── a tiny PNG toolchain (spec-level, no image library) ─────────────────────
|
||||
|
||||
const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
function crc32(buf: Buffer): number {
|
||||
let c = ~0;
|
||||
for (const byte of buf) {
|
||||
c ^= byte;
|
||||
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
|
||||
}
|
||||
return ~c >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type: string, data: Buffer): Buffer {
|
||||
const out = Buffer.alloc(12 + data.length);
|
||||
out.writeUInt32BE(data.length, 0);
|
||||
out.write(type, 4, "ascii");
|
||||
data.copy(out, 8);
|
||||
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function walkChunks(png: Buffer): Array<{ type: string; data: Buffer }> {
|
||||
assert.ok(png.subarray(0, 8).equals(PNG_SIG), "PNG signature");
|
||||
const chunks: Array<{ type: string; data: Buffer }> = [];
|
||||
let off = 8;
|
||||
while (off < png.length) {
|
||||
const len = png.readUInt32BE(off);
|
||||
const type = png.subarray(off + 4, off + 8).toString("ascii");
|
||||
chunks.push({ type, data: png.subarray(off + 8, off + 8 + len) });
|
||||
off += 12 + len;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/** Build a VALID multi-IDAT PNG — exactly Chrome's shape: one IHDR, the
|
||||
* compressed scanlines split across ≥2 IDAT chunks, one IEND. */
|
||||
function chromeStyleMultiIdatPng(width = 4, height = 4): Buffer {
|
||||
// Raw scanlines: filter byte 0 + width × RGB pixels (white) — the filter
|
||||
// byte MUST be 0–4 (libpng rejects anything else).
|
||||
const stride = 1 + width * 3;
|
||||
const raw = Buffer.alloc(stride * height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const row = raw.subarray(y * stride, (y + 1) * stride);
|
||||
row[0] = 0; // filter: none
|
||||
row.fill(255, 1);
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 2; // color type: truecolor RGB
|
||||
const compressed = zlib.deflateSync(raw);
|
||||
const split = Math.max(1, Math.floor(compressed.length / 2));
|
||||
return Buffer.concat([
|
||||
PNG_SIG,
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", compressed.subarray(0, split)),
|
||||
chunk("IDAT", compressed.subarray(split)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── the tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
test("providerSafePng merges a Chrome-style split IDAT stream into ONE chunk, byte-identical stream", async () => {
|
||||
const chromeStyle = chromeStyleMultiIdatPng();
|
||||
const inChunks = walkChunks(chromeStyle);
|
||||
assert.equal(
|
||||
inChunks.filter((c) => c.type === "IDAT").length,
|
||||
2,
|
||||
"fixture sanity: the input is the multi-IDAT shape",
|
||||
);
|
||||
|
||||
const fixed = providerSafePng(chromeStyle);
|
||||
const outChunks = walkChunks(fixed);
|
||||
const outIdats = outChunks.filter((c) => c.type === "IDAT");
|
||||
assert.equal(outIdats.length, 1, "exactly one IDAT chunk — the form Alibaba's decoder accepts");
|
||||
// The canonical property: the merged chunk's payload is the BYTE-IDENTICAL
|
||||
// zlib stream (a transparent re-split, no re-encoding at all).
|
||||
assert.ok(
|
||||
outIdats[0]!.data.equals(Buffer.concat(inChunks.filter((c) => c.type === "IDAT").map((c) => c.data))),
|
||||
"the zlib stream is untouched",
|
||||
);
|
||||
// Structure preserved: same non-IDAT chunks in order, valid CRCs (walkChunks
|
||||
// is dumb, so verify CRC explicitly for the merged chunk).
|
||||
assert.deepEqual(
|
||||
outChunks.map((c) => c.type),
|
||||
["IHDR", "IDAT", "IEND"],
|
||||
);
|
||||
const fixedBuf = fixed;
|
||||
let off = 8;
|
||||
while (off < fixedBuf.length) {
|
||||
const len = fixedBuf.readUInt32BE(off);
|
||||
const crc = fixedBuf.readUInt32BE(off + 8 + len);
|
||||
assert.equal(crc, crc32(fixedBuf.subarray(off + 4, off + 8 + len)), `valid CRC on ${fixedBuf.subarray(off + 4, off + 8).toString("ascii")}`);
|
||||
off += 12 + len;
|
||||
}
|
||||
// And it is still a decodable image: the stream inflates to EXACTLY the
|
||||
// original scanlines (filter byte 0 + white RGB pixels).
|
||||
const stream = walkChunks(fixed).find((c) => c.type === "IDAT")!.data;
|
||||
const stride = 1 + 4 * 3;
|
||||
const expected = Buffer.alloc(stride * 4);
|
||||
for (let y = 0; y < 4; y++) {
|
||||
const row = expected.subarray(y * stride, (y + 1) * stride);
|
||||
row[0] = 0;
|
||||
row.fill(255, 1);
|
||||
}
|
||||
assert.ok(zlib.inflateSync(stream).equals(expected), "inflates to the original raw scanlines");
|
||||
});
|
||||
|
||||
test("a single-IDAT PNG is already canonical: returned byte-identical (no-op)", () => {
|
||||
// One-IDAT input → one IDAT out, same bytes — the normal case (e.g. the
|
||||
// doctrine-gate re-render of an already-canonical PNG).
|
||||
const stride = 1 + 4 * 3;
|
||||
const raw = Buffer.alloc(stride * 4);
|
||||
for (let y = 0; y < 4; y++) {
|
||||
const row = raw.subarray(y * stride, (y + 1) * stride);
|
||||
row[0] = 0;
|
||||
row.fill(200, 1);
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(4, 0);
|
||||
ihdr.writeUInt32BE(4, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 2;
|
||||
const single = Buffer.concat([
|
||||
PNG_SIG,
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", zlib.deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
assert.ok(providerSafePng(single).equals(single), "byte-identical — canonical input is untouched");
|
||||
});
|
||||
|
||||
test("non-PNG or truncated input passes through unchanged (defensive)", () => {
|
||||
const notPng = Buffer.from("definitely not a png");
|
||||
assert.ok(providerSafePng(notPng).equals(notPng));
|
||||
const truncated = chromeStyleMultiIdatPng().subarray(0, 20);
|
||||
assert.ok(providerSafePng(truncated).equals(truncated));
|
||||
});
|
||||
+80
-1
@@ -49,7 +49,8 @@ import { createTeacherTools } from "../src/agent/tools.ts";
|
||||
import { webTools } from "../src/agent/tools/web.ts";
|
||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
|
||||
import { abortJob, claimSteers, deliverJobResult, setTurnRunner } from "../src/server/jobs.ts";
|
||||
import { abortJob, claimSteers, deliverJobResult, recoverStaleJobs, setJobRespawn, setTurnRunner } from "../src/server/jobs.ts";
|
||||
import { registerJobAbort, unregisterJobAbort } from "../src/agent/subagents/aborts.ts";
|
||||
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
|
||||
import { persistTurn, withMessageIds } from "../src/server/turn-service.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
@@ -1120,3 +1121,81 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
||||
assert.deepEqual(abortJob(job.id), { ok: false, reason: "not_running" });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 7. crash recovery, LIVENESS-GATED (observability-accuracy wave, 2026-09-19) ──
|
||||
//
|
||||
// The sweep (recoverStaleJobs) keys on the abort registry (subagents/aborts.ts
|
||||
// isJobLive), not record age: a "running" record with a live registration
|
||||
// belongs to a loop executing in THIS process (hung or slow — never re-run
|
||||
// beside itself); without one it is a process-death orphan and recovers at
|
||||
// once. The old 15-minute STALE_JOB_MS heuristic is gone — these tests prove
|
||||
// immediacy by running the sweep on FRESH records.
|
||||
|
||||
describe("stale-job recovery sweep (liveness-gated)", () => {
|
||||
/** Reset the sweep's 30s throttle — tests fire it directly, repeatedly. */
|
||||
function resetSweepThrottle(): void {
|
||||
const g = globalThis as { __lhWakePipeline?: { lastSweepAt: number } };
|
||||
if (g.__lhWakePipeline) g.__lhWakePipeline.lastSweepAt = 0;
|
||||
}
|
||||
|
||||
/** A FRESH "running" record (no loop registration): the orphan shape.
|
||||
* Freshness is the point — the old code needed 15 minutes of age. */
|
||||
function orphanJob(withResult?: ResearchResult): { chatId: string; jobId: string } {
|
||||
const chat = chatStore.createChat("Test: stale sweep");
|
||||
trackChat(chat.id);
|
||||
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] }] as never);
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: `call-sweep-${createdJobs.length}` });
|
||||
trackJob(job.id);
|
||||
if (withResult) jobStore.updateJob(job.id, { result: withResult, activity: "delivering result" });
|
||||
return { chatId: chat.id, jobId: job.id };
|
||||
}
|
||||
|
||||
it("a running record with a LIVE loop registration is never recovered — however old (the double-run guard)", () => {
|
||||
const { jobId } = orphanJob();
|
||||
// Backdate the record well past every plausible in-flight run: age
|
||||
// alone must NOT trigger recovery anymore.
|
||||
jobStore.updateJob(jobId, { updatedAt: new Date(Date.now() - 30 * 60_000).toISOString() });
|
||||
registerJobAbort(jobId, new AbortController()); // a loop is executing
|
||||
let respawned = 0;
|
||||
setJobRespawn(() => {
|
||||
respawned++;
|
||||
});
|
||||
resetSweepThrottle();
|
||||
recoverStaleJobs();
|
||||
assert.equal(respawned, 0, "a live loop is provably executing — no respawn beside it");
|
||||
assert.equal(jobStore.getJob(jobId)!.activity, "starting", "the record is untouched");
|
||||
// Hermetic teardown: settle + unregister so later sweeps in THIS suite
|
||||
// never see this fixture as a fresh orphan.
|
||||
jobStore.updateJob(jobId, { status: "done", activity: "settled (test)" });
|
||||
unregisterJobAbort(jobId);
|
||||
});
|
||||
|
||||
it("a running record WITHOUT a registration is an orphan: the loop is re-spawned immediately (no age wait)", () => {
|
||||
const { jobId } = orphanJob();
|
||||
let respawned: string | null = null;
|
||||
setJobRespawn((id) => {
|
||||
respawned = id;
|
||||
});
|
||||
resetSweepThrottle();
|
||||
recoverStaleJobs();
|
||||
assert.equal(respawned, jobId, "the orphan's loop is re-spawned at once");
|
||||
assert.equal(jobStore.getJob(jobId)!.activity, "recovering: re-running");
|
||||
// Hermetic teardown (see above) — the respawned loop is the TEST's
|
||||
// fake seam, nothing real runs.
|
||||
jobStore.updateJob(jobId, { status: "done", activity: "settled (test)" });
|
||||
});
|
||||
|
||||
it("an orphan WITH a stored result is re-delivered: the wake fires and the job flips done", async () => {
|
||||
const { jobId } = orphanJob({ brief: "Done: it works.", sources: [] });
|
||||
let fired = 0;
|
||||
setTurnRunner(async () => {
|
||||
fired++;
|
||||
return 200;
|
||||
});
|
||||
resetSweepThrottle();
|
||||
recoverStaleJobs();
|
||||
await waitFor(() => fired > 0, 8000, "recovery wake fired");
|
||||
await waitFor(() => jobStore.getJob(jobId)!.status !== "running", 8000, "orphan settled after re-delivery");
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "done");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Pure tests for the shared pan/zoom viewport math (components/pan-zoom.ts),
|
||||
* harvested from the plan popup so the mermaid focus overlay runs the same
|
||||
* fit-floor geometry: fully zoomed out IS the fit, panning locks at the
|
||||
* floor (and re-centers), wheel zooms to the cursor, ceiling 4×. Pure
|
||||
* function coverage — no DOM, no rendering (the overlays' live pointer
|
||||
* wiring is exercised in the browser; these tests pin the math it runs).
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { FIT_ZOOM_MAX, ZOOM_MAX, fitGraphView, fitZoomFor, zoomedGraphView } from "../components/pan-zoom.ts";
|
||||
|
||||
describe("fitZoomFor", () => {
|
||||
it("binds on the axis that runs out of room (wide box → width)", () => {
|
||||
assert.equal(fitZoomFor({ w: 800, h: 400 }, { w: 400, h: 300 }), 384 / 800);
|
||||
});
|
||||
|
||||
it("binds on height for a tall box", () => {
|
||||
assert.equal(fitZoomFor({ w: 400, h: 800 }, { w: 400, h: 300 }), 284 / 800);
|
||||
});
|
||||
|
||||
it("caps the fit at FIT_ZOOM_MAX — a tiny box must not blow up", () => {
|
||||
assert.equal(fitZoomFor({ w: 50, h: 50 }, { w: 1000, h: 1000 }), FIT_ZOOM_MAX);
|
||||
});
|
||||
|
||||
it("degenerate viewport (zero-sized) renders at 1×", () => {
|
||||
assert.equal(fitZoomFor({ w: 400, h: 300 }, { w: 0, h: 0 }), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitGraphView", () => {
|
||||
it("centers the box at the given zoom", () => {
|
||||
assert.deepEqual(fitGraphView({ w: 800, h: 400 }, { w: 400, h: 300 }, 0.5), {
|
||||
x: (400 - 400) / 2,
|
||||
y: (300 - 200) / 2,
|
||||
zoom: 0.5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("zoomedGraphView", () => {
|
||||
const box = { w: 800, h: 400 };
|
||||
const viewport = { w: 400, h: 300 };
|
||||
const floor = fitZoomFor(box, viewport);
|
||||
|
||||
it("zooms to the focus point: the focus stays visually fixed", () => {
|
||||
const out = zoomedGraphView({ x: 0, y: 0, zoom: 1 }, 1.25, floor, { x: 200, y: 150 }, box, viewport);
|
||||
assert.equal(out.zoom, 1.25);
|
||||
// focus.x - x_out = (focus.x - x_in) * k → the cursor anchors.
|
||||
assert.equal(200 - out.x, 200 * 1.25);
|
||||
assert.equal(150 - out.y, 150 * 1.25);
|
||||
});
|
||||
|
||||
it("clamps at the floor and re-fits + RE-CENTERS (the fit is the floor)", () => {
|
||||
const nearFloor = { x: -100, y: 40, zoom: floor * 1.1 };
|
||||
const out = zoomedGraphView(nearFloor, 0.5, floor, { x: 200, y: 150 }, box, viewport);
|
||||
assert.deepEqual(out, fitGraphView(box, viewport, floor));
|
||||
});
|
||||
|
||||
it("clamps at ZOOM_MAX", () => {
|
||||
const atMax = { x: 0, y: 0, zoom: ZOOM_MAX };
|
||||
const out = zoomedGraphView(atMax, 2, floor, { x: 200, y: 150 }, box, viewport);
|
||||
assert.deepEqual(out, atMax);
|
||||
});
|
||||
|
||||
it("no-ops when the factor lands exactly on the current zoom", () => {
|
||||
const view = { x: -37, y: 12, zoom: 1 };
|
||||
const out = zoomedGraphView(view, 1, floor, { x: 0, y: 0 }, box, viewport);
|
||||
assert.equal(out, view);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import React from "react";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Composer } from "../components/composer.tsx";
|
||||
import { Composer, enterAction } from "../components/composer.tsx";
|
||||
|
||||
function render(opts: { busy?: boolean; canSend?: boolean } = {}) {
|
||||
return renderToStaticMarkup(
|
||||
@@ -49,3 +49,25 @@ describe("Composer", () => {
|
||||
assert.ok(/aria-label="Send"[^>]*disabled|disabled[^>]*aria-label="Send"/.test(html), "send disabled");
|
||||
});
|
||||
});
|
||||
|
||||
// The Enter decision (user-decided 2026-09-19, docs/probe-teach-split.md §4):
|
||||
// while the plan verdict pends, Enter must ACCEPT the plan — formerly it sent
|
||||
// a plain message, leaving the verdict pending again on the next message.
|
||||
// Pure function so the static-render harness can pin it (key events can't be
|
||||
// simulated in renderToStaticMarkup).
|
||||
describe("enterAction", () => {
|
||||
it("busy stream: Enter stops — verdict or not", () => {
|
||||
assert.equal(enterAction({ busy: true, verdictPending: false, canSend: true }), "stop");
|
||||
assert.equal(enterAction({ busy: true, verdictPending: true, canSend: true }), "stop");
|
||||
});
|
||||
|
||||
it("verdict pends: Enter ACCEPTS — with text (the note rides) and with an empty box alike", () => {
|
||||
assert.equal(enterAction({ busy: false, verdictPending: true, canSend: true }), "accept", "typed text rides the acceptance note");
|
||||
assert.equal(enterAction({ busy: false, verdictPending: true, canSend: false }), "accept", "empty box still accepts — Enter can never strand the verdict");
|
||||
});
|
||||
|
||||
it("no verdict: send when there is text, nothing when there is not", () => {
|
||||
assert.equal(enterAction({ busy: false, verdictPending: false, canSend: true }), "send");
|
||||
assert.equal(enterAction({ busy: false, verdictPending: false, canSend: false }), "none");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Render tests for the chrome-melt wave (2026-09-19): the md-blocks
|
||||
* component overrides (Streamdown `components` prop) + the session-scoped
|
||||
* overlays. Idiom shared with render-markdown.test.tsx: static HTML via
|
||||
* react-dom/server (no DOM, effects never run — mermaid stays at its
|
||||
* skeleton, the overlays render their shells). tsx note: classic JSX
|
||||
* transform — the React global shim below provides React.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MessageResponse } from "../components/ai-elements/message.tsx";
|
||||
import { MermaidOverlay, SessionMermaidContext, chartSize } from "../components/session-overlay.tsx";
|
||||
import { PlanPopup } from "../components/plan-block.tsx";
|
||||
import { MarkdownCode, codeSource } from "../components/md-blocks.tsx";
|
||||
|
||||
function render(md: string): string {
|
||||
return renderToStaticMarkup(<MessageResponse mode="static">{md}</MessageResponse>);
|
||||
}
|
||||
|
||||
describe("md-blocks — code", () => {
|
||||
it("inline code passes through with the house inline styling", () => {
|
||||
const html = render("a `term` b");
|
||||
assert.ok(html.includes('data-streamdown="inline-code"'), "inline code keeps the inline-code path");
|
||||
assert.match(html, /bg-muted/);
|
||||
});
|
||||
|
||||
it("code fences delegate to streamdown's CodeBlock with a hover copy corner", () => {
|
||||
const html = render("```js\nconst x = 1;\n```");
|
||||
assert.ok(html.includes('data-testid="md-code"'), "the md-blocks wrapper mounts");
|
||||
assert.ok(html.includes('data-streamdown="code-block"'), "the delegated CodeBlock path is kept");
|
||||
assert.ok(html.includes('data-streamdown="code-block-body"'), "shiki body renders");
|
||||
assert.match(html, /aria-label="Copy code"/, "the corner copy affordance is present");
|
||||
// NAMED group (group/md) — the message container is also a `group`,
|
||||
// and an unnamed group-hover would light every corner in the message at
|
||||
// once (owner report, 2026-09-19); only the hovered block reveals.
|
||||
assert.ok(html.includes("group/md"), "the block uses the named md group");
|
||||
assert.match(html, /group-hover\/md:opacity-100/, "the corner reveals via the named group only");
|
||||
// No "Copied" pill — the icon flip alone conveys the copy (owner ask,
|
||||
// 2026-09-19; the pill was tried and removed same day).
|
||||
assert.ok(!html.includes('data-testid="md-copy-pill"'), "no Copied pill anywhere — icon flip is the feedback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("md-blocks — mermaid", () => {
|
||||
it("renders as the bare app-owned block (skeleton in static render), never a code block, source never leaks", () => {
|
||||
const html = render("```mermaid\ngraph TD\nA --> B\n```");
|
||||
assert.ok(html.includes('data-testid="md-mermaid"'), "the app-owned mermaid block mounts");
|
||||
assert.ok(
|
||||
!html.includes('data-streamdown="code-block"'),
|
||||
"mermaid fences must not render as code blocks",
|
||||
);
|
||||
assert.ok(!html.includes("graph TD"), "chart source is not leaked as text");
|
||||
// No chat page mounted → no overlay context → no expand button.
|
||||
assert.ok(!html.includes('data-testid="md-mermaid-expand"'), "expand hidden without a session context");
|
||||
});
|
||||
});
|
||||
|
||||
describe("md-blocks — tables", () => {
|
||||
it("renders as a plain prose table with the copy corner — no wrapper box, no button rows", () => {
|
||||
const html = render("| A | B |\n| --- | --- |\n| 1 | 2 |");
|
||||
assert.ok(html.includes('data-testid="md-table"'), "the md-blocks table wrapper mounts");
|
||||
assert.ok(html.includes('data-streamdown="table"'), "the table itself renders");
|
||||
assert.ok(!html.includes('data-streamdown="table-wrapper"'), "streamdown's boxed wrapper is gone");
|
||||
assert.match(html, /aria-label="Copy table"/, "the corner copy affordance is present");
|
||||
assert.ok(!html.match(/Fullscreen|aria-label="Download"/), "no fullscreen/download chrome on tables");
|
||||
// The corner is CONTAINED in the table (top-1 right-1, not the old
|
||||
// -top-3 that straddled the top outline — the 24px button at a 12px
|
||||
// offset sat half over the border; owner report, 2026-09-19).
|
||||
assert.match(html, /absolute top-1 right-1 z-10/, "the corner sits inside the table's top-right");
|
||||
// Table text runs at the session prose size — no text-sm pin (the
|
||||
// cells' own text-sm is overridden to inherit via globals.css).
|
||||
assert.ok(!/border-collapse text-sm/.test(html), "the table itself pins no smaller text size");
|
||||
});
|
||||
});
|
||||
|
||||
describe("md-blocks — display math", () => {
|
||||
it("the block corner reveals ONLY on its own block — named group, not the message-level group (owner report: all-at-once)", () => {
|
||||
const md = "```js\nconst x = 1;\n```\n\n| A | B |\n| --- | --- |\n| 1 | 2 |";
|
||||
const html = render(md);
|
||||
// No UNNAMED group-hover on the corners: that selector would fire from
|
||||
// the Message container's own `group` class and light every block.
|
||||
assert.ok(!html.match(/\s group-hover:opacity-100/), "no unnamed group-hover on any corner");
|
||||
assert.ok(html.includes('data-testid="md-table"'), "table present for the contrast");
|
||||
});
|
||||
|
||||
it("keeps katex-display rendering and adds the TeX copy corner", () => {
|
||||
const html = render("$$\n\\int_0^1 x^2\\,dx = \\tfrac{1}{3}\n$$");
|
||||
assert.ok(html.includes("katex-display"), "display math still renders via KaTeX");
|
||||
assert.ok(html.includes('data-testid="md-math"'), "the md-blocks math wrapper mounts");
|
||||
assert.match(html, /aria-label="Copy equation source"/, "the corner copy affordance is present");
|
||||
// The wrapper is BLOCK: the katex-display child is display:block, and
|
||||
// an inline wrapper around it fragments — the absolute corner then has
|
||||
// no clean containing block and pointer events never land (the math
|
||||
// corner neither highlighted nor copied; owner report, 2026-09-19).
|
||||
assert.match(html, /group\/md relative block/, "the math wrapper is a block box");
|
||||
assert.match(html, /absolute top-0 right-0 z-10/, "the corner stacks above the equation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("md-blocks — codeSource (pure)", () => {
|
||||
it("extracts the fence source from string children and element-wrapped children", () => {
|
||||
assert.equal(codeSource("const x = 1;"), "const x = 1;");
|
||||
assert.equal(codeSource(<span>const x = 1;</span>), "const x = 1;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session-scoped overlays (chrome-melt wave, 2026-09-19)", () => {
|
||||
it("mermaid overlay mounts inside the session area — absolute (not fixed), 80% + blur, closeable", () => {
|
||||
const html = renderToStaticMarkup(<MermaidOverlay chart="graph TD\nA --> B" onClose={() => {}} />);
|
||||
assert.match(html, /class="absolute inset-0[^"]*z-40/, "absolute inset-0 z-40 — scoped, not fullscreen");
|
||||
assert.ok(html.includes("bg-background/80"), "80% background: the transcript ghosts through");
|
||||
assert.ok(html.includes("backdrop-blur"), "blur surface");
|
||||
assert.ok(!html.includes("fixed inset-0"), "never fixed — the drag bar stays uncovered");
|
||||
assert.ok(html.includes('data-testid="mermaid-overlay-close"'), "the close X is present");
|
||||
assert.match(html, /role="dialog"/, "dialog semantics for Esc/aria");
|
||||
});
|
||||
|
||||
it("the plan popup is session-scoped the same way, and its chart is hidden until the first fit (no top-left flash)", () => {
|
||||
const html = renderToStaticMarkup(<PlanPopup messages={[]} onClose={() => {}} />);
|
||||
assert.match(html, /data-testid="plan-popup"/);
|
||||
assert.match(html, /class="absolute inset-0[^"]*z-40/, "absolute inset-0 z-40 — scoped, not fullscreen");
|
||||
assert.ok(html.includes("bg-background/80"), "80% background");
|
||||
assert.ok(html.includes("backdrop-blur"), "blur surface");
|
||||
assert.ok(!html.includes("fixed inset-0"), "never fixed — the drag bar stays uncovered");
|
||||
});
|
||||
|
||||
it("the diagram wrapper is hidden until the first fit applies (no top-left flash — owner report, 2026-09-19)", () => {
|
||||
// Covered in render-plan-block.test.tsx, which has a real tree
|
||||
// fixture (an empty-messages popup renders no diagram to gate) — and
|
||||
// the same gate is asserted on the mermaid overlay's wrapper here:
|
||||
// settled=false at static render → opacity 0.
|
||||
const html = renderToStaticMarkup(<MermaidOverlay chart="graph TD\nA --> B" onClose={() => {}} />);
|
||||
// The chart itself hasn't rendered at static render (async effect) —
|
||||
// the opacity gate is pinned on the plan popup's diagram wrapper in
|
||||
// render-plan-block.test.tsx, which has a real tree fixture (the two
|
||||
// overlays share the settled-gating pattern).
|
||||
assert.ok(html.includes('data-testid="mermaid-overlay-close"'), "overlay shell renders");
|
||||
});
|
||||
|
||||
it("chartSize reads the viewBox from a rendered svg (fallback for degenerate ones)", () => {
|
||||
assert.deepEqual(
|
||||
chartSize('<svg viewBox="0 0 900 700" xmlns="..."></svg>'),
|
||||
{ w: 900, h: 700 },
|
||||
);
|
||||
assert.deepEqual(chartSize("<svg></svg>"), { w: 600, h: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mermaid expand — the context seam", () => {
|
||||
it("the expand button renders when a session context is mounted (static: chart stays at skeleton, so assert the seam via the provider wiring)", () => {
|
||||
// The full path (skeleton → chart → hover expand) needs effects/DOM;
|
||||
// what static rendering CAN pin is that a provider value flows: the
|
||||
// default context is null (hidden button, covered above) and a real
|
||||
// provider is the one md-blocks consumes. The overlay's open-state
|
||||
// owner is chat.tsx — geometry covered by the tests above.
|
||||
const opened: string[] = [];
|
||||
const html = renderToStaticMarkup(
|
||||
<SessionMermaidContext.Provider value={{ openMermaid: (chart) => void opened.push(chart) }}>
|
||||
<div />
|
||||
</SessionMermaidContext.Provider>,
|
||||
);
|
||||
assert.ok(html.length > 0, "provider renders");
|
||||
assert.deepEqual(opened, [], "nothing opened by rendering alone");
|
||||
});
|
||||
});
|
||||
@@ -193,6 +193,12 @@ describe("plan popup (live derivation)", () => {
|
||||
// Arrows into not-yet-visited nodes are grayed out (owner, round 6).
|
||||
assert.match(html, /marker-end="url\(#.*-dim\)"/);
|
||||
assert.match(html, /live — statuses derive from your check outcomes/);
|
||||
// No top-left flash: at static render settled=false, so the diagram
|
||||
// wrapper paints at opacity 0 — the fit runs in an effect AFTER the
|
||||
// first paint and the chart would otherwise flash at the initial
|
||||
// top-left zoom:1 state for a frame (owner report, 2026-09-19;
|
||||
// transition-gating alone didn't hide it).
|
||||
assert.match(html, /opacity: ?0[;,}]/, "diagram hidden until the first fit applies");
|
||||
});
|
||||
|
||||
it("renders the empty state when the session has no plan", () => {
|
||||
|
||||
Reference in New Issue
Block a user