test(assistant): add portable identity browser regression

Release-line could reproduce tool identity and shared evidence regressions but could not replay browser evidence tied to private capture paths. Supply a synthetic standalone real reducer/transcript journey with blocked backend and external requests, desktop and narrow assertions, and content-hashed receipts. This verifies renderer behaviour without claiming provider, production or release admission qualification.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-06 12:29:50 +01:00
parent a8c1b067d6
commit 2d36bb7d4d
4 changed files with 351 additions and 0 deletions
@@ -0,0 +1,37 @@
# Assistant identity browser regression
From the repository root (Node 24):
```sh
npm ci --ignore-scripts
npm ci --ignore-scripts --prefix frontend-modern
npx playwright install chromium
pulse-heavy-run -- node scripts/check-assistant-identity.mjs
```
This standalone Vite fixture imports the current checkout's real `useChat` and
`ChatMessages`, including `MessageItem`, approval cards and tool detail rows.
It is not a production entry point and needs no backend, credentials, provider,
customer captures or live infrastructure. The only replaced API is the chat
stream callback; all browser API/external HTTP requests are blocked and fail the
check. Approval completion is synthetic, not evidence of an authorised action.
Checks at 1440, 900 and 390 pixels cover concurrent same-name invocation IDs,
progress/completion isolation, retaining a sibling approval after completion,
settled rendered results, and shared input/output evidence after removal of a
workflow row. Both evidence rows are expanded and document overflow is checked.
The shared-object journey deliberately retains references, matching the original
regression rather than concealing it with cloned fixtures.
Output defaults to `tmp/assistant-identity`; set `ASSISTANT_IDENTITY_OUTPUT` to
an absolute path to retain a candidate-specific run. A successful run writes
screenshots and `receipt.json` with browser version and SHA-256 source hashes.
Run on the actual backport checkout: a main receipt does not qualify release
content. This is a component/reducer browser proof, not the release admission
attestation, a production-build check, SSE transport qualification, provider
reasoning proof, or installed end-to-end action verification.
Regression sensitivity can be checked by temporarily substituting either
`ChatMessages.tsx` or `hooks/useChat.ts` from the parent of fix `33b852f66b`:
each must fail independently. Restore files afterwards; never admit those
experimental substitutions as candidate content.
@@ -0,0 +1,72 @@
// Synthetic component journey: real reducer and transcript, no application server.
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { AIChatAPI, type StreamEvent } from '../../src/api/aiChat';
import { useChat } from '../../src/components/AI/Chat/hooks/useChat';
import { ChatMessages } from '../../src/components/AI/Chat/ChatMessages';
import type { ChatMessage } from '../../src/components/AI/Chat/types';
import '../../src/index.css';
let dispatch: (event: StreamEvent) => void;
AIChatAPI.chat = async (_prompt, _session, _model, onEvent) => {
dispatch = onEvent;
await new Promise(() => {}); // Keep stream open until the fixture is disposed.
};
const noOp = () => {};
function Fixture() {
const chat = useChat({ sessionId: 'synthetic-identity' });
const [override, setOverride] = createSignal<ChatMessage[] | null>(null);
const a = { name: 'pulse_query', input: 'client', output: 'client evidence', success: true };
const b = { name: 'pulse_alerts', input: 'alerts', output: 'alert evidence', success: true };
const pendingA = { id: 'a', name: a.name, input: a.input };
const pendingB = { id: 'b', name: b.name, input: b.input };
const workflow = {
type: 'workflow_status' as const,
workflowStatus: { phase: 'provider_start', message: 'Starting' },
};
const message = (extra: Partial<ChatMessage>): ChatMessage => ({
id: 'shared',
role: 'assistant',
content: '',
timestamp: new Date('2026-01-01T00:00:00Z'),
...extra,
});
Object.assign(window, {
identityFixture: {
start: () => {
void chat.sendMessage('Synthetic identity check');
},
fire: (event: StreamEvent) => dispatch(event),
snapshot: () => chat.messages().find((m) => m.role === 'assistant'),
evidence: () => [a, b],
shared: (stage: number) =>
setOverride([
message({
toolCalls: stage === 0 ? [] : stage === 1 ? [a] : [a, b],
pendingTools: stage === 0 ? [pendingA, pendingB] : stage === 1 ? [pendingB] : [],
streamEvents: [
...(stage < 3 ? [workflow] : []),
stage === 0
? { type: 'pending_tool', toolId: 'a', pendingTool: pendingA }
: { type: 'tool', toolId: 'a', tool: a },
stage < 2
? { type: 'pending_tool', toolId: 'b', pendingTool: pendingB }
: { type: 'tool', toolId: 'b', tool: b },
],
}),
]),
},
});
return (
<main style={{ height: '100vh', display: 'flex', 'flex-direction': 'column' }}>
<ChatMessages
messages={override() ?? chat.messages()}
onApprove={(id, approval) => chat.updateApproval(id, approval.toolId, { removed: true })}
onSkip={(id, toolId) => chat.updateApproval(id, toolId, { removed: true })}
onAnswerQuestion={noOp}
onSkipQuestion={noOp}
/>
</main>
);
}
render(() => <Fixture />, document.getElementById('root')!);
@@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./fixture.tsx"></script>
</body>
</html>
+232
View File
@@ -0,0 +1,232 @@
// Run with pulse-heavy-run -- node scripts/check-assistant-identity.mjs
import { chromium, expect } from "../node_modules/@playwright/test/index.mjs";
import { createServer } from "../frontend-modern/node_modules/vite/dist/node/index.js";
import solid from "../frontend-modern/node_modules/vite-plugin-solid/dist/esm/index.mjs";
import { resolve } from "node:path";
import { mkdir, readFile, writeFile, readdir, rm } from "node:fs/promises";
import { createHash } from "node:crypto";
const root = resolve(import.meta.dirname, "../frontend-modern");
const output = resolve(
process.env.ASSISTANT_IDENTITY_OUTPUT || `${root}/../tmp/assistant-identity`,
);
await mkdir(output, { recursive: true });
await rm(`${output}/receipt.json`, { force: true });
process.chdir(root);
const server = await createServer({
configFile: false,
root,
optimizeDeps: { esbuildOptions: { target: "esnext" } },
plugins: [solid()],
resolve: { alias: { "@": `${root}/src` } },
server: { host: "127.0.0.1", port: 0 },
});
let browser;
const results = [];
try {
await server.listen();
const base = server.resolvedUrls.local[0];
browser = await chromium.launch({ headless: true });
for (const width of [1440, 900, 390]) {
const context = await browser.newContext({
viewport: { width, height: 1000 },
serviceWorkers: "block",
});
const page = await context.newPage();
const errors = [],
blocked = [];
page.on("pageerror", (e) => errors.push(e.message));
await context.route("**/*", (route) => {
const url = new URL(route.request().url());
if (
url.origin === new URL(base).origin &&
!url.pathname.startsWith("/api/")
)
return route.continue();
blocked.push(url.pathname);
return route.abort();
});
await page.goto(`${base}qualification/assistant-identity/`);
await page.waitForFunction(() => !!window.identityFixture);
const call = (method, arg) =>
page.evaluate(
([method, arg]) => window.identityFixture[method](arg),
[method, arg],
);
const fire = (type, data) => call("fire", { type, data });
await call("start");
for (const id of ["a", "b"])
await fire("tool_start", {
id,
name: "pulse_query",
input: JSON.stringify({ action: "search", query: `fixture-${id}` }),
});
expect((await call("snapshot")).pendingTools.map((t) => t.id)).toEqual([
"a",
"b",
]);
await fire("tool_progress", {
id: "a",
name: "pulse_query",
message: "Reading A",
});
await fire("tool_end", {
id: "a",
name: "pulse_query",
output: "identity evidence A",
success: true,
});
expect((await call("snapshot")).pendingTools.map((t) => t.id)).toEqual([
"b",
]);
await fire("tool_end", {
id: "b",
name: "pulse_query",
output: "identity evidence B",
success: false,
});
expect((await call("snapshot")).toolCalls.map((t) => t.output)).toEqual([
"identity evidence A",
"identity evidence B",
]);
for (const id of ["c", "d"]) {
await fire("tool_start", {
id,
name: "pulse_control",
input: JSON.stringify({ resource_id: id }),
});
await fire("approval_needed", {
tool_id: id,
tool_name: "pulse_control",
approval_id: `approval-${id}`,
command: `synthetic-${id}`,
});
}
await expect(
page.getByText("Approval Required", { exact: true }),
).toHaveCount(2);
await fire("tool_end", {
id: "c",
name: "pulse_control",
output: "synthetic c completed",
success: true,
});
await expect(
page.getByText("Approval Required", { exact: true }),
).toHaveCount(1);
expect(
(await call("snapshot")).pendingApprovals.map((a) => a.toolId),
).toEqual(["d"]);
await expect(page.getByText("synthetic-d", { exact: true })).toBeVisible();
await page.screenshot({
path: `${output}/approvals-${width}.png`,
fullPage: true,
});
await fire("done", {});
while (await page.locator('[aria-expanded="false"]').count())
await page.locator('[aria-expanded="false"]').first().click();
await expect(
page.getByText("identity evidence A", { exact: true }).last(),
).toBeVisible();
await expect(
page.getByText("identity evidence B", { exact: true }).last(),
).toBeVisible();
for (let stage = 0; stage < 4; stage++) {
await call("shared", stage);
// Allow Solid's render effects to run between immutable snapshots.
await page.evaluate(() => new Promise(requestAnimationFrame));
}
expect(await call("evidence")).toEqual([
{
name: "pulse_query",
input: "client",
output: "client evidence",
success: true,
},
{
name: "pulse_alerts",
input: "alerts",
output: "alert evidence",
success: true,
},
]);
const expand = page.locator('[role="button"][aria-expanded="false"]');
while (await expand.count()) await expand.first().click();
await expect(
page.getByText("client evidence", { exact: true }).last(),
).toBeVisible();
await expect(
page.getByText("alert evidence", { exact: true }).last(),
).toBeVisible();
await expect(
page.getByText("client", { exact: true }).last(),
).toBeVisible();
await expect(
page.getByText("alerts", { exact: true }).last(),
).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
await page.screenshot({
path: `${output}/expanded-${width}.png`,
fullPage: true,
});
expect(errors).toEqual([]);
expect(blocked).toEqual([]);
results.push({ width, passed: true });
await context.close();
}
const hashes = {};
for (const file of await readdir(output)) {
if (file.endsWith(".png"))
hashes[`artifacts/${file}`] = createHash("sha256")
.update(await readFile(`${output}/${file}`))
.digest("hex");
}
hashes["../package-lock.json"] = createHash("sha256")
.update(await readFile(`${root}/../package-lock.json`))
.digest("hex");
async function hashTree(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory()) await hashTree(path);
else
hashes[path.slice(root.length + 1)] = createHash("sha256")
.update(await readFile(path))
.digest("hex");
}
}
await hashTree(`${root}/src`);
await hashTree(`${root}/qualification/assistant-identity`);
for (const path of [
"package-lock.json",
"tailwind.config.js",
"postcss.config.js",
])
hashes[path] = createHash("sha256")
.update(await readFile(`${root}/${path}`))
.digest("hex");
hashes["../scripts/check-assistant-identity.mjs"] = createHash("sha256")
.update(await readFile(import.meta.filename))
.digest("hex");
await writeFile(
`${output}/receipt.json`,
JSON.stringify(
{
recordedAt: new Date().toISOString(),
scope:
"Synthetic reducer and renderer only; no provider, backend or real actions qualified",
browser: browser.version(),
results,
hashes,
},
null,
2,
),
);
console.log(JSON.stringify({ output, results }));
} finally {
await browser?.close();
await server.close();
}