Files
pulse/scripts/check-assistant-identity.mjs
T
rcourtman 9e7a5ffdeb Move frontend to Tailwind CSS 4 and jscpd 5 to drop braces
GHSA-vfj7-8cjw-p6xm affects every braces release and has no fix, so the
required frontend audit failed with seven high findings on any pull
request that changes the dependency graph. Tailwind CSS 3 (chokidar,
fast-glob, micromatch) and jscpd 4 (@jscpd/finder, fast-glob) were the
only paths in, both dev-only, and no override can remove braces from
fast-glob or micromatch. Tailwind CSS 4.3.3 through @tailwindcss/vite and
jscpd 5.4.0 leave none; npm audit over the full graph reports zero.

The stylesheet keeps the v3 rendering. It pins the v3 palette (inlined,
so opacity modifiers compile to static colors), font stacks and line
heights, keeps v3's cascade order by placing former component and
unlayered rules around the generated utilities, and restores v3 hover on
touch devices, gray-200 default borders, the 3px blue ring, placeholder
color, button cursor, table-cell padding and the space-* and divide-*
child selectors. The 900/950 shades Pulse overrode with 25% alpha are
opaque now and their callers say /25, because v4 multiplies an opacity
modifier into a translucent color instead of replacing it. Opacity
modifiers on var() colors that v3 never generated are removed.

Utility order changed from lexical to numeric, so a few caller overrides
that silently lost in v3 would start to win. Touch targets keep their
authored 44px phone size (the Machines row stays 32px); cosmetic
overrides v3 never applied are removed, and overrides it did apply use
the important suffix.

Contract-Neutral: Tailwind CSS 4 migration for GHSA-vfj7-8cjw-p6xm: utility class renames and stylesheet compatibility, no subsystem contract change
2026-10-03 15:19:35 +01:00

229 lines
7.3 KiB
JavaScript

// 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"])
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();
}