Minor bug fix

This commit is contained in:
James Brunton
2026-08-28 15:15:48 +01:00
parent ee0ae22c3c
commit 763cb2dcca
3 changed files with 78 additions and 2 deletions
@@ -48,6 +48,12 @@ const mocks = vi.hoisted(() => ({
meter: vi.fn(),
}));
// The second (file-producing) policy's timing, flippable per test to prove classification's local
// pass is independent of when the rewriter runs.
const securityRunOn = vi.hoisted(() => ({
value: "upload" as "upload" | "export",
}));
vi.mock("@app/hooks/useAiEngineEnabled", () => ({
useAiEngineEnabled: () => true,
}));
@@ -86,7 +92,7 @@ vi.mock("@app/hooks/usePolicies", () => ({
runsOnEditor: true,
status: "active",
backendId: "backend-security",
runOn: "upload",
runOn: securityRunOn.value,
order: 1,
outputMode: "new_version",
outputName: "",
@@ -176,6 +182,7 @@ beforeEach(() => {
mocks.backendOutCounter = 0;
mocks.dispatchInFlight = 0;
mocks.maxDispatchInFlight = 0;
securityRunOn.value = "upload";
mocks.workspace = Array.from({ length: FILE_COUNT }, (_, i) => ({
id: `file-${i}`,
@@ -356,4 +363,29 @@ describe("policy auto-run — 61-file batch through a Security → Classificatio
expect(mocks.consumeSilentCalls).toBe(0);
expect(mocks.persistCalls).toBeGreaterThan(0);
});
it("classifies uploads even when the other editor policy runs on export, not upload", async () => {
// Repro: with a file-producing policy set to export, the auto-run engine dispatches nothing on
// upload - but classification's local pass is independent and must still run on every upload.
securityRunOn.value = "export";
renderHook(() => Harness());
await act(async () => {
await vi.waitFor(
() => {
const classification = latestRuns.filter(
(r) => r.categoryId === "classification" && r.imported,
);
expect(classification).toHaveLength(FILE_COUNT);
},
{ timeout: 8000, interval: 20 },
);
});
// The export policy did not run on upload; nothing versioned in place.
expect(latestRuns.filter((r) => r.categoryId === "security")).toHaveLength(
0,
);
expect(mocks.consumeSilentCalls).toBe(0);
});
});
@@ -60,6 +60,43 @@ describe("policyStorage", () => {
expect(p.routing.configured).toBe(false);
});
it("migrates a pre-runsOnEditor row narrowed to non-editor sources off the editor", () => {
// Stored before runsOnEditor existed: no such field, sources exclude the editor.
localStorage.setItem(
"stirling-policies-state",
JSON.stringify({
security: { configured: true, status: "active", sources: ["s3"] },
}),
);
// Without the migration the default (true) would wrongly win.
expect(loadPolicies().security.runsOnEditor).toBe(false);
});
it("migrates a pre-runsOnEditor row listing the editor onto the editor", () => {
localStorage.setItem(
"stirling-policies-state",
JSON.stringify({
security: { configured: true, status: "active", sources: ["editor"] },
}),
);
expect(loadPolicies().security.runsOnEditor).toBe(true);
});
it("leaves an explicit runsOnEditor untouched", () => {
localStorage.setItem(
"stirling-policies-state",
JSON.stringify({
security: {
configured: true,
status: "active",
sources: ["editor"],
runsOnEditor: false,
},
}),
);
expect(loadPolicies().security.runsOnEditor).toBe(false);
});
it("fires a change event on update", () => {
const cb = vi.fn();
const off = onPoliciesChange(cb);
@@ -55,7 +55,14 @@ export function loadPolicies(): PoliciesByCategory {
// category gets a default rather than being undefined.
const out: PoliciesByCategory = {};
loadPolicyCatalog().categories.forEach((cat, index) => {
const merged = { ...defaultState(cat.id), ...(parsed[cat.id] ?? {}) };
const stored = parsed[cat.id];
const merged = { ...defaultState(cat.id), ...(stored ?? {}) };
// Migration: a row stored before runsOnEditor existed has no such field, so the default (true)
// would put a tile narrowed to non-editor sources on the editor until the first reconcile lands.
// Derive it from the legacy signal (the editor in its sources), mirroring the decode rule.
if (stored && stored.runsOnEditor === undefined) {
merged.runsOnEditor = (stored.sources ?? []).includes("editor");
}
// Migration: clear the obsolete persisted reviewer email so it re-defaults
// to the real signed-in user.
if (merged.reviewerEmail === STALE_REVIEWER_EMAIL)