feat(m2): ship isolated task dashboard surface

This commit is contained in:
2026-08-03 23:11:29 +08:00
parent 9fd321c41c
commit ccff57b2a1
9 changed files with 829 additions and 9 deletions
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { SurfaceInstanceManager } from "./surface-instance-manager";
import { SurfaceRegistry } from "./surface-registry";
const APP = "lineup.task-dashboard";
const VERSION = "0.1.0";
const CONVERSATION = "conversation_surface_001";
const OPENED = "2026-08-03T10:30:00.000Z";
const UPDATED = "2026-08-03T10:31:00.000Z";
function createManager(): { registry: SurfaceRegistry; manager: SurfaceInstanceManager } {
const registry = new SurfaceRegistry();
registry.enable(APP, VERSION, OPENED);
return { registry, manager: new SurfaceInstanceManager(registry) };
}
function openPayload(state: object = { title: "Build", percent: 10 }): object {
return { instance_id: "task:001", app: { app_id: APP, version: VERSION }, state };
}
describe("SurfaceInstanceManager", () => {
it("opens only an explicitly enabled local app and makes same-instance open idempotent", () => {
const { manager } = createManager();
expect(manager.open(openPayload(), CONVERSATION, OPENED)).toMatchObject({ disposition: "accepted", instance: { phase: "opening", state: { title: "Build", percent: 10 } } });
expect(manager.open(openPayload({ title: "Ignored patch" }), CONVERSATION, UPDATED)).toMatchObject({ disposition: "duplicate", instance: { state: { title: "Build", percent: 10 } } });
});
it("rejects an unenabled app, an app-version mismatch, and injected bundle fields", () => {
const registry = new SurfaceRegistry();
const manager = new SurfaceInstanceManager(registry);
expect(manager.open(openPayload(), CONVERSATION, OPENED).disposition).toBe("disabled");
registry.enable(APP, VERSION, OPENED);
expect(manager.open({ ...openPayload(), app: { app_id: APP, version: "1.0.0" } }, CONVERSATION, OPENED).disposition).toBe("version_mismatch");
expect(manager.open({ ...openPayload(), bundle: "<script>" }, CONVERSATION, OPENED).disposition).toBe("bundle_override_forbidden");
});
it("moves to ready once and permits only state replacement through patch", () => {
const { manager } = createManager();
manager.open(openPayload(), CONVERSATION, OPENED);
expect(manager.ready("task:001", UPDATED)).toMatchObject({ disposition: "accepted", instance: { phase: "ready" } });
expect(manager.ready("task:001", UPDATED).disposition).toBe("duplicate");
expect(manager.patch("task:001", { title: "Build", percent: 60 }, UPDATED)).toMatchObject({ disposition: "accepted", instance: { state: { title: "Build", percent: 60 }, app_id: APP, version: VERSION } });
expect(manager.patch("task:001", { date: new Date() }, UPDATED).disposition).toBe("invalid_state");
});
it("closes instances and never restores disabled, malformed, or unknown snapshots", () => {
const { registry, manager } = createManager();
manager.open(openPayload(), CONVERSATION, OPENED);
const snapshot = manager.snapshot();
expect(manager.close("task:001").disposition).toBe("accepted");
expect(manager.get("task:001")).toBeUndefined();
manager.restore(snapshot);
expect(manager.get("task:001")).toMatchObject({ phase: "opening", state: { title: "Build", percent: 10 } });
registry.disable(APP, VERSION);
manager.restore(snapshot);
expect(manager.snapshot().instances).toEqual([]);
manager.restore({ version: 1, instances: [{ instance_id: "bad", conversation_id: CONVERSATION, app_id: "unknown.surface", version: VERSION, state: {}, phase: "ready", opened_at: OPENED, updated_at: UPDATED }] });
expect(manager.snapshot().instances).toEqual([]);
});
it("treats a conflicting reuse of an instance id as a safe no-op", () => {
const { manager } = createManager();
manager.open(openPayload(), CONVERSATION, OPENED);
expect(manager.open(openPayload(), "another_conversation", UPDATED)).toMatchObject({ disposition: "conflict", instance: { conversation_id: CONVERSATION } });
expect(manager.patch("missing", {}, UPDATED).disposition).toBe("missing");
expect(manager.close("missing").disposition).toBe("missing");
});
});