@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +26 -5
- package/README.md +49 -7
- package/docs/architecture.md +129 -1
- package/package.json +15 -15
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
- package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
- package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
- package/packages/extension/src/ask-user-tool.ts +1 -1
- package/packages/extension/src/bridge-context.ts +68 -4
- package/packages/extension/src/bridge.ts +79 -11
- package/packages/extension/src/command-handler.ts +95 -15
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/multiselect-list.ts +1 -1
- package/packages/extension/src/pi-env.d.ts +16 -9
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/provider-register.ts +16 -9
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/session-sync.ts +10 -1
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +8 -7
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
- package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
- package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service.test.ts +2 -2
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
- package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
- package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
- package/packages/server/src/__tests__/package-routes.test.ts +1 -1
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
- package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
- package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +27 -10
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
- package/packages/server/src/changelog-fs.ts +167 -0
- package/packages/server/src/changelog-parser.ts +321 -0
- package/packages/server/src/changelog-remote.ts +134 -0
- package/packages/server/src/cli.ts +62 -82
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +90 -6
- package/packages/server/src/headless-pid-registry.ts +344 -37
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/pending-client-correlations.ts +73 -0
- package/packages/server/src/pending-fork-registry.ts +24 -12
- package/packages/server/src/pi-core-checker.ts +77 -17
- package/packages/server/src/pi-core-updater.ts +16 -6
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +182 -11
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/provider-catalogue-cache.ts +24 -18
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
- package/packages/server/src/routes/pi-core-routes.ts +1 -1
- package/packages/server/src/routes/provider-auth-routes.ts +8 -1
- package/packages/server/src/routes/provider-routes.ts +28 -5
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +254 -60
- package/packages/server/src/session-api.ts +63 -4
- package/packages/server/src/session-discovery.ts +1 -1
- package/packages/server/src/session-file-reader.ts +1 -1
- package/packages/server/src/spawn-register-watchdog.ts +62 -7
- package/packages/server/src/spawn-token.ts +20 -0
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +70 -0
- package/packages/shared/src/changelog-types.ts +111 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +71 -26
- package/packages/shared/src/protocol.ts +27 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/skill-block-parser.ts +1 -1
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/tool-registry/definitions.ts +15 -3
- package/packages/shared/src/types.ts +62 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
- package/packages/shared/src/resolve-jiti.ts +0 -102
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the OpenSpec change-grouping store.
|
|
3
|
+
*
|
|
4
|
+
* Covers tasks 2.15–2.17 + spec scenarios from
|
|
5
|
+
* `openspec/changes/add-openspec-change-grouping/specs/openspec-change-grouping/spec.md`.
|
|
6
|
+
*
|
|
7
|
+
* See change: add-openspec-change-grouping.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
10
|
+
import fs from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import {
|
|
14
|
+
createOpenSpecGroupStore,
|
|
15
|
+
ConcurrentEditError,
|
|
16
|
+
UnsupportedSchemaVersionError,
|
|
17
|
+
GroupNotFoundError,
|
|
18
|
+
UnknownGroupIdError,
|
|
19
|
+
type OpenSpecGroupStore,
|
|
20
|
+
} from "../openspec-group-store.js";
|
|
21
|
+
import { OPENSPEC_GROUPS_SCHEMA_VERSION } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
22
|
+
|
|
23
|
+
describe("openspec-group-store", () => {
|
|
24
|
+
let tmpDir: string;
|
|
25
|
+
let cwd: string;
|
|
26
|
+
let groupsFile: string;
|
|
27
|
+
let store: OpenSpecGroupStore;
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ogs-"));
|
|
31
|
+
cwd = tmpDir;
|
|
32
|
+
groupsFile = path.join(cwd, "openspec", "groups", "groups.json");
|
|
33
|
+
store = createOpenSpecGroupStore();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
store.dispose();
|
|
38
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ── Read path ────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe("read()", () => {
|
|
44
|
+
it("returns the default-empty payload when the file is absent and does NOT create the directory", async () => {
|
|
45
|
+
const data = await store.read(cwd);
|
|
46
|
+
expect(data).toEqual({ schemaVersion: 1, groups: [], assignments: {} });
|
|
47
|
+
|
|
48
|
+
const dirExists = await fs.stat(path.join(cwd, "openspec", "groups")).then(
|
|
49
|
+
() => true,
|
|
50
|
+
() => false,
|
|
51
|
+
);
|
|
52
|
+
expect(dirExists).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns parsed contents verbatim when file present", async () => {
|
|
56
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
57
|
+
await fs.writeFile(
|
|
58
|
+
groupsFile,
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
schemaVersion: 1,
|
|
61
|
+
groups: [{ id: "ui", name: "UI", color: "#3b82f6", order: 0 }],
|
|
62
|
+
assignments: { "add-foo": "ui" },
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
const data = await store.read(cwd);
|
|
66
|
+
expect(data.groups).toEqual([{ id: "ui", name: "UI", color: "#3b82f6", order: 0 }]);
|
|
67
|
+
expect(data.assignments).toEqual({ "add-foo": "ui" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns cached data on unchanged (mtime, size) without calling readFile", async () => {
|
|
71
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
72
|
+
await fs.writeFile(
|
|
73
|
+
groupsFile,
|
|
74
|
+
JSON.stringify({ schemaVersion: 1, groups: [], assignments: {} }),
|
|
75
|
+
);
|
|
76
|
+
// Prime the cache.
|
|
77
|
+
await store.read(cwd);
|
|
78
|
+
const readSpy = vi.spyOn(fs, "readFile");
|
|
79
|
+
// Second read should be a cache hit — no readFile.
|
|
80
|
+
await store.read(cwd);
|
|
81
|
+
await store.read(cwd);
|
|
82
|
+
expect(readSpy).not.toHaveBeenCalled();
|
|
83
|
+
readSpy.mockRestore();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("re-reads when mtime changes", async () => {
|
|
87
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
88
|
+
await fs.writeFile(
|
|
89
|
+
groupsFile,
|
|
90
|
+
JSON.stringify({ schemaVersion: 1, groups: [], assignments: {} }),
|
|
91
|
+
);
|
|
92
|
+
const first = await store.read(cwd);
|
|
93
|
+
expect(first.groups).toHaveLength(0);
|
|
94
|
+
// Bump mtime and content.
|
|
95
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
96
|
+
const future = new Date(Date.now() + 60_000);
|
|
97
|
+
await fs.writeFile(
|
|
98
|
+
groupsFile,
|
|
99
|
+
JSON.stringify({
|
|
100
|
+
schemaVersion: 1,
|
|
101
|
+
groups: [{ id: "ui", name: "UI", order: 0 }],
|
|
102
|
+
assignments: {},
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
await fs.utimes(groupsFile, future, future);
|
|
106
|
+
const second = await store.read(cwd);
|
|
107
|
+
expect(second.groups).toHaveLength(1);
|
|
108
|
+
expect(second.groups[0]?.id).toBe("ui");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("detects within-same-second writes via size delta", async () => {
|
|
112
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
113
|
+
const base = { schemaVersion: 1, groups: [], assignments: {} };
|
|
114
|
+
await fs.writeFile(groupsFile, JSON.stringify(base));
|
|
115
|
+
const stat1 = await fs.stat(groupsFile);
|
|
116
|
+
await store.read(cwd);
|
|
117
|
+
// Write with same mtime but larger size.
|
|
118
|
+
const bigger = {
|
|
119
|
+
schemaVersion: 1,
|
|
120
|
+
groups: [{ id: "ui", name: "UI", order: 0 }],
|
|
121
|
+
assignments: {},
|
|
122
|
+
};
|
|
123
|
+
await fs.writeFile(groupsFile, JSON.stringify(bigger));
|
|
124
|
+
await fs.utimes(groupsFile, stat1.atime, stat1.mtime); // force same mtime
|
|
125
|
+
const data = await store.read(cwd);
|
|
126
|
+
expect(data.groups).toHaveLength(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("clears the cache and returns default when the file is deleted", async () => {
|
|
130
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
131
|
+
await fs.writeFile(
|
|
132
|
+
groupsFile,
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
schemaVersion: 1,
|
|
135
|
+
groups: [{ id: "ui", name: "UI", order: 0 }],
|
|
136
|
+
assignments: {},
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
const first = await store.read(cwd);
|
|
140
|
+
expect(first.groups).toHaveLength(1);
|
|
141
|
+
await fs.rm(groupsFile);
|
|
142
|
+
const second = await store.read(cwd);
|
|
143
|
+
expect(second).toEqual({ schemaVersion: 1, groups: [], assignments: {} });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("shares one readFile call across concurrent reads in the same tick", async () => {
|
|
147
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
148
|
+
await fs.writeFile(
|
|
149
|
+
groupsFile,
|
|
150
|
+
JSON.stringify({ schemaVersion: 1, groups: [], assignments: {} }),
|
|
151
|
+
);
|
|
152
|
+
const readSpy = vi.spyOn(fs, "readFile");
|
|
153
|
+
const reads = await Promise.all([store.read(cwd), store.read(cwd), store.read(cwd), store.read(cwd), store.read(cwd)]);
|
|
154
|
+
expect(reads.every((r) => r.schemaVersion === 1)).toBe(true);
|
|
155
|
+
// First batch: 1 readFile call shared across 5 reads.
|
|
156
|
+
expect(readSpy).toHaveBeenCalledTimes(1);
|
|
157
|
+
readSpy.mockRestore();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rejects unknown schemaVersion with UnsupportedSchemaVersionError", async () => {
|
|
161
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
162
|
+
await fs.writeFile(groupsFile, JSON.stringify({ schemaVersion: 2, groups: [], assignments: {} }));
|
|
163
|
+
await expect(store.read(cwd)).rejects.toBeInstanceOf(UnsupportedSchemaVersionError);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("rejects missing schemaVersion field with UnsupportedSchemaVersionError", async () => {
|
|
167
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
168
|
+
await fs.writeFile(groupsFile, JSON.stringify({ groups: [], assignments: {} }));
|
|
169
|
+
await expect(store.read(cwd)).rejects.toBeInstanceOf(UnsupportedSchemaVersionError);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ── createGroup ──────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
describe("createGroup()", () => {
|
|
176
|
+
it("creates the file + directory on first write", async () => {
|
|
177
|
+
const g = await store.createGroup(cwd, { name: "UI", color: "#3b82f6" });
|
|
178
|
+
expect(g.id).toBe("ui");
|
|
179
|
+
expect(g.name).toBe("UI");
|
|
180
|
+
expect(g.order).toBe(0);
|
|
181
|
+
const raw = await fs.readFile(groupsFile, "utf-8");
|
|
182
|
+
const parsed = JSON.parse(raw);
|
|
183
|
+
expect(parsed.schemaVersion).toBe(OPENSPEC_GROUPS_SCHEMA_VERSION);
|
|
184
|
+
expect(parsed.groups).toHaveLength(1);
|
|
185
|
+
expect(parsed.assignments).toEqual({});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("appends collision suffix when slug already exists", async () => {
|
|
189
|
+
await store.createGroup(cwd, { name: "UI" });
|
|
190
|
+
const second = await store.createGroup(cwd, { name: "UI" });
|
|
191
|
+
expect(second.id).toBe("ui-2");
|
|
192
|
+
const third = await store.createGroup(cwd, { name: "UI" });
|
|
193
|
+
expect(third.id).toBe("ui-3");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("auto-assigns order = groups.length", async () => {
|
|
197
|
+
const a = await store.createGroup(cwd, { name: "UI" });
|
|
198
|
+
const b = await store.createGroup(cwd, { name: "Server" });
|
|
199
|
+
expect(a.order).toBe(0);
|
|
200
|
+
expect(b.order).toBe(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("serializes concurrent creates for the same cwd via FIFO mutex", async () => {
|
|
204
|
+
const [a, b, c] = await Promise.all([
|
|
205
|
+
store.createGroup(cwd, { name: "A" }),
|
|
206
|
+
store.createGroup(cwd, { name: "B" }),
|
|
207
|
+
store.createGroup(cwd, { name: "C" }),
|
|
208
|
+
]);
|
|
209
|
+
const data = await store.read(cwd);
|
|
210
|
+
expect(data.groups).toHaveLength(3);
|
|
211
|
+
const ids = new Set(data.groups.map((g) => g.id));
|
|
212
|
+
expect(ids.has(a.id)).toBe(true);
|
|
213
|
+
expect(ids.has(b.id)).toBe(true);
|
|
214
|
+
expect(ids.has(c.id)).toBe(true);
|
|
215
|
+
// Orders are contiguous 0..2.
|
|
216
|
+
const orders = data.groups.map((g) => g.order).sort();
|
|
217
|
+
expect(orders).toEqual([0, 1, 2]);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ── updateGroup ──────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
describe("updateGroup()", () => {
|
|
224
|
+
it("renames without changing id", async () => {
|
|
225
|
+
const g = await store.createGroup(cwd, { name: "UI" });
|
|
226
|
+
const updated = await store.updateGroup(cwd, g.id, { name: "Frontend" });
|
|
227
|
+
expect(updated.id).toBe("ui");
|
|
228
|
+
expect(updated.name).toBe("Frontend");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("throws GroupNotFoundError on unknown id", async () => {
|
|
232
|
+
await expect(store.updateGroup(cwd, "does-not-exist", { name: "X" })).rejects.toBeInstanceOf(
|
|
233
|
+
GroupNotFoundError,
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("normalizes order to contiguous 0..N-1 after a reorder", async () => {
|
|
238
|
+
const a = await store.createGroup(cwd, { name: "A" }); // order 0
|
|
239
|
+
const b = await store.createGroup(cwd, { name: "B" }); // order 1
|
|
240
|
+
const c = await store.createGroup(cwd, { name: "C" }); // order 2
|
|
241
|
+
// Move A to the end.
|
|
242
|
+
await store.updateGroup(cwd, a.id, { order: 5 });
|
|
243
|
+
const data = await store.read(cwd);
|
|
244
|
+
const byId = new Map(data.groups.map((g) => [g.id, g.order]));
|
|
245
|
+
const orders = [...byId.values()].sort();
|
|
246
|
+
expect(orders).toEqual([0, 1, 2]);
|
|
247
|
+
// A should be last (highest order).
|
|
248
|
+
expect(byId.get(a.id)).toBe(2);
|
|
249
|
+
expect(byId.get(b.id)).toBe(0);
|
|
250
|
+
expect(byId.get(c.id)).toBe(1);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ── deleteGroup ──────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
describe("deleteGroup()", () => {
|
|
257
|
+
it("removes the group", async () => {
|
|
258
|
+
const g = await store.createGroup(cwd, { name: "UI" });
|
|
259
|
+
await store.deleteGroup(cwd, g.id);
|
|
260
|
+
const data = await store.read(cwd);
|
|
261
|
+
expect(data.groups).toHaveLength(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("cascades through assignments referencing the deleted group", async () => {
|
|
265
|
+
const g = await store.createGroup(cwd, { name: "UI" });
|
|
266
|
+
const other = await store.createGroup(cwd, { name: "Server" });
|
|
267
|
+
await store.setAssignment(cwd, "add-foo", g.id);
|
|
268
|
+
await store.setAssignment(cwd, "fix-bar", other.id);
|
|
269
|
+
await store.deleteGroup(cwd, g.id);
|
|
270
|
+
const data = await store.read(cwd);
|
|
271
|
+
expect(data.assignments).toEqual({ "fix-bar": other.id });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("throws GroupNotFoundError on unknown id", async () => {
|
|
275
|
+
await expect(store.deleteGroup(cwd, "does-not-exist")).rejects.toBeInstanceOf(GroupNotFoundError);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("re-packs orders contiguously after delete", async () => {
|
|
279
|
+
const a = await store.createGroup(cwd, { name: "A" });
|
|
280
|
+
const b = await store.createGroup(cwd, { name: "B" });
|
|
281
|
+
const c = await store.createGroup(cwd, { name: "C" });
|
|
282
|
+
await store.deleteGroup(cwd, b.id);
|
|
283
|
+
const data = await store.read(cwd);
|
|
284
|
+
const byId = new Map(data.groups.map((g) => [g.id, g.order]));
|
|
285
|
+
// Surviving orders should be contiguous 0..1.
|
|
286
|
+
const orders = [...byId.values()].sort();
|
|
287
|
+
expect(orders).toEqual([0, 1]);
|
|
288
|
+
expect(byId.get(a.id)).toBe(0);
|
|
289
|
+
expect(byId.get(c.id)).toBe(1);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ── setAssignment ────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
describe("setAssignment()", () => {
|
|
296
|
+
it("adds a single map entry", async () => {
|
|
297
|
+
const g = await store.createGroup(cwd, { name: "UI" });
|
|
298
|
+
await store.setAssignment(cwd, "add-foo", g.id);
|
|
299
|
+
const data = await store.read(cwd);
|
|
300
|
+
expect(data.assignments).toEqual({ "add-foo": g.id });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("replaces previous group on reassignment", async () => {
|
|
304
|
+
const g1 = await store.createGroup(cwd, { name: "UI" });
|
|
305
|
+
const g2 = await store.createGroup(cwd, { name: "Server" });
|
|
306
|
+
await store.setAssignment(cwd, "add-foo", g1.id);
|
|
307
|
+
await store.setAssignment(cwd, "add-foo", g2.id);
|
|
308
|
+
const data = await store.read(cwd);
|
|
309
|
+
expect(data.assignments).toEqual({ "add-foo": g2.id });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("removes the entry on null", async () => {
|
|
313
|
+
const g = await store.createGroup(cwd, { name: "UI" });
|
|
314
|
+
await store.setAssignment(cwd, "add-foo", g.id);
|
|
315
|
+
await store.setAssignment(cwd, "add-foo", null);
|
|
316
|
+
const data = await store.read(cwd);
|
|
317
|
+
expect(data.assignments).toEqual({});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("throws UnknownGroupIdError on unknown groupId", async () => {
|
|
321
|
+
await expect(store.setAssignment(cwd, "add-foo", "does-not-exist")).rejects.toBeInstanceOf(
|
|
322
|
+
UnknownGroupIdError,
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("tolerates unknown changeName", async () => {
|
|
327
|
+
const g = await store.createGroup(cwd, { name: "UI" });
|
|
328
|
+
await store.setAssignment(cwd, "never-existed", g.id);
|
|
329
|
+
const data = await store.read(cwd);
|
|
330
|
+
expect(data.assignments).toEqual({ "never-existed": g.id });
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ── Concurrency: FIFO mutex + cwd isolation ──────────────────
|
|
335
|
+
|
|
336
|
+
describe("concurrency", () => {
|
|
337
|
+
it("two concurrent writes to the same cwd produce both results (no lost write)", async () => {
|
|
338
|
+
// Both should serialize and both succeed.
|
|
339
|
+
const [a, b] = await Promise.all([
|
|
340
|
+
store.createGroup(cwd, { name: "A" }),
|
|
341
|
+
store.createGroup(cwd, { name: "B" }),
|
|
342
|
+
]);
|
|
343
|
+
const data = await store.read(cwd);
|
|
344
|
+
const ids = new Set(data.groups.map((g) => g.id));
|
|
345
|
+
expect(ids.has(a.id)).toBe(true);
|
|
346
|
+
expect(ids.has(b.id)).toBe(true);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("concurrent writes to different cwds proceed in parallel", async () => {
|
|
350
|
+
const tmpDir2 = await fs.mkdtemp(path.join(os.tmpdir(), "ogs2-"));
|
|
351
|
+
try {
|
|
352
|
+
const [a, b] = await Promise.all([
|
|
353
|
+
store.createGroup(cwd, { name: "A" }),
|
|
354
|
+
store.createGroup(tmpDir2, { name: "B" }),
|
|
355
|
+
]);
|
|
356
|
+
expect(a.id).toBe("a");
|
|
357
|
+
expect(b.id).toBe("b");
|
|
358
|
+
} finally {
|
|
359
|
+
await fs.rm(tmpDir2, { recursive: true, force: true });
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ── 1-shot retry on hand-edit race ───────────────────────────
|
|
365
|
+
|
|
366
|
+
describe("hand-edit race detection", () => {
|
|
367
|
+
it("retries once and succeeds when the file is hand-edited between read and rename", async () => {
|
|
368
|
+
let triggered = false;
|
|
369
|
+
const racingStore = createOpenSpecGroupStore({
|
|
370
|
+
__testHookBeforeRename: async () => {
|
|
371
|
+
if (triggered) return;
|
|
372
|
+
triggered = true;
|
|
373
|
+
// Simulate a hand-edit: write a different version of the file.
|
|
374
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
375
|
+
const future = new Date(Date.now() + 60_000);
|
|
376
|
+
await fs.writeFile(
|
|
377
|
+
groupsFile,
|
|
378
|
+
JSON.stringify({
|
|
379
|
+
schemaVersion: 1,
|
|
380
|
+
groups: [{ id: "external", name: "External", order: 0 }],
|
|
381
|
+
assignments: {},
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
await fs.utimes(groupsFile, future, future);
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
try {
|
|
388
|
+
const created = await racingStore.createGroup(cwd, { name: "UI" });
|
|
389
|
+
expect(created.name).toBe("UI");
|
|
390
|
+
// Final file should contain BOTH the external group and the dashboard one.
|
|
391
|
+
const data = await racingStore.read(cwd);
|
|
392
|
+
const ids = new Set(data.groups.map((g) => g.id));
|
|
393
|
+
expect(ids.has("external")).toBe(true);
|
|
394
|
+
expect(ids.has(created.id)).toBe(true);
|
|
395
|
+
} finally {
|
|
396
|
+
racingStore.dispose();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("throws ConcurrentEditError after a sustained race (two strikes)", async () => {
|
|
401
|
+
let strikes = 0;
|
|
402
|
+
const racingStore = createOpenSpecGroupStore({
|
|
403
|
+
__testHookBeforeRename: async () => {
|
|
404
|
+
strikes++;
|
|
405
|
+
// Always race — simulate sustained external editor.
|
|
406
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
407
|
+
const future = new Date(Date.now() + 60_000 * strikes);
|
|
408
|
+
await fs.writeFile(
|
|
409
|
+
groupsFile,
|
|
410
|
+
JSON.stringify({
|
|
411
|
+
schemaVersion: 1,
|
|
412
|
+
groups: [{ id: "external", name: `External-${strikes}`, order: 0 }],
|
|
413
|
+
assignments: {},
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
await fs.utimes(groupsFile, future, future);
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
try {
|
|
420
|
+
await expect(racingStore.createGroup(cwd, { name: "UI" })).rejects.toBeInstanceOf(
|
|
421
|
+
ConcurrentEditError,
|
|
422
|
+
);
|
|
423
|
+
// Hook fires twice: once for the original write, once for the retry.
|
|
424
|
+
expect(strikes).toBe(2);
|
|
425
|
+
} finally {
|
|
426
|
+
racingStore.dispose();
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// ── Subscribe / debounced broadcast ──────────────────────────
|
|
432
|
+
|
|
433
|
+
describe("subscribe() — debounced broadcast", () => {
|
|
434
|
+
it("emits a single broadcast for one write within the debounce window", async () => {
|
|
435
|
+
vi.useFakeTimers();
|
|
436
|
+
const fast = createOpenSpecGroupStore({ debounceMs: 100 });
|
|
437
|
+
const events: Array<{ cwd: string; payload: unknown }> = [];
|
|
438
|
+
fast.subscribe((cwd, payload) => events.push({ cwd, payload }));
|
|
439
|
+
try {
|
|
440
|
+
await fast.createGroup(cwd, { name: "UI" });
|
|
441
|
+
// Before the debounce window elapses, no broadcast yet.
|
|
442
|
+
expect(events).toHaveLength(0);
|
|
443
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
444
|
+
expect(events).toHaveLength(1);
|
|
445
|
+
expect(events[0]?.cwd).toBe(cwd);
|
|
446
|
+
} finally {
|
|
447
|
+
vi.useRealTimers();
|
|
448
|
+
fast.dispose();
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("coalesces 5 writes within 100 ms into 1 broadcast", async () => {
|
|
453
|
+
vi.useFakeTimers();
|
|
454
|
+
const fast = createOpenSpecGroupStore({ debounceMs: 100 });
|
|
455
|
+
const events: Array<{ cwd: string; payload: { groups: Array<{ id: string }> } }> = [];
|
|
456
|
+
fast.subscribe((c, p) => events.push({ cwd: c, payload: p as any }));
|
|
457
|
+
try {
|
|
458
|
+
await fast.createGroup(cwd, { name: "A" });
|
|
459
|
+
await fast.createGroup(cwd, { name: "B" });
|
|
460
|
+
await fast.createGroup(cwd, { name: "C" });
|
|
461
|
+
await fast.createGroup(cwd, { name: "D" });
|
|
462
|
+
await fast.createGroup(cwd, { name: "E" });
|
|
463
|
+
// Still inside debounce window.
|
|
464
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
465
|
+
expect(events).toHaveLength(0);
|
|
466
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
467
|
+
expect(events).toHaveLength(1);
|
|
468
|
+
expect(events[0]?.payload.groups).toHaveLength(5);
|
|
469
|
+
} finally {
|
|
470
|
+
vi.useRealTimers();
|
|
471
|
+
fast.dispose();
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("does NOT coalesce broadcasts across different cwds", async () => {
|
|
476
|
+
const tmpDir2 = await fs.mkdtemp(path.join(os.tmpdir(), "ogs2-"));
|
|
477
|
+
vi.useFakeTimers();
|
|
478
|
+
const fast = createOpenSpecGroupStore({ debounceMs: 100 });
|
|
479
|
+
const events: Array<{ cwd: string }> = [];
|
|
480
|
+
fast.subscribe((c) => events.push({ cwd: c }));
|
|
481
|
+
try {
|
|
482
|
+
await fast.createGroup(cwd, { name: "A" });
|
|
483
|
+
await fast.createGroup(tmpDir2, { name: "B" });
|
|
484
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
485
|
+
expect(events).toHaveLength(2);
|
|
486
|
+
const cwds = new Set(events.map((e) => e.cwd));
|
|
487
|
+
expect(cwds.has(cwd)).toBe(true);
|
|
488
|
+
expect(cwds.has(tmpDir2)).toBe(true);
|
|
489
|
+
} finally {
|
|
490
|
+
vi.useRealTimers();
|
|
491
|
+
fast.dispose();
|
|
492
|
+
await fs.rm(tmpDir2, { recursive: true, force: true });
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Tests for loadPiPackageManager() resolution chain in package-manager-wrapper.ts.
|
|
3
3
|
*
|
|
4
4
|
* Separate from package-manager-wrapper.test.ts because that file mocks
|
|
5
|
-
* "@
|
|
5
|
+
* "@earendil-works/pi-coding-agent" so direct-import succeeds and the
|
|
6
6
|
* fallback paths never execute.
|
|
7
7
|
*
|
|
8
8
|
* These tests exercise the managed-install and global-npm fallbacks.
|
|
@@ -15,10 +15,10 @@ import * as path from "node:path";
|
|
|
15
15
|
// Force the direct import to fail so resolution falls through to the
|
|
16
16
|
// managed-install / global-npm paths. vi.mock is hoisted; the factory
|
|
17
17
|
// throws at import time which mimics pi not being an installed dependency.
|
|
18
|
-
vi.mock("@
|
|
18
|
+
vi.mock("@earendil-works/pi-coding-agent", () => {
|
|
19
19
|
throw new Error("not installed as direct dependency");
|
|
20
20
|
});
|
|
21
|
-
vi.mock("@
|
|
21
|
+
vi.mock("@mariozechner/pi-coding-agent", () => {
|
|
22
22
|
throw new Error("not installed as direct dependency");
|
|
23
23
|
});
|
|
24
24
|
|
|
@@ -60,7 +60,7 @@ describe("loadPiPackageManager resolution chain", () => {
|
|
|
60
60
|
tmpHome,
|
|
61
61
|
".pi-dashboard",
|
|
62
62
|
"node_modules",
|
|
63
|
-
"@
|
|
63
|
+
"@earendil-works",
|
|
64
64
|
"pi-coding-agent",
|
|
65
65
|
"dist",
|
|
66
66
|
);
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "../package-manager-wrapper.js";
|
|
14
14
|
|
|
15
15
|
// Mock pi dependency (pulled transitively by package-manager-wrapper)
|
|
16
|
-
vi.mock("@
|
|
16
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
17
17
|
DefaultPackageManager: function() { return {}; },
|
|
18
18
|
SettingsManager: { create: () => ({}) },
|
|
19
19
|
}));
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { createPendingForkRegistry } from "../pending-fork-registry.js";
|
|
3
3
|
|
|
4
|
+
// See change: spawn-correlation-token \u2014 registry is now keyed by spawnToken
|
|
5
|
+
// (UUID minted by the server per spawn invocation) instead of cwd. This
|
|
6
|
+
// closes the multi-fork-in-same-cwd race where the second `recordFork`
|
|
7
|
+
// would overwrite the first's parentSessionId.
|
|
8
|
+
|
|
4
9
|
describe("PendingForkRegistry", () => {
|
|
5
10
|
beforeEach(() => {
|
|
6
11
|
vi.useFakeTimers();
|
|
@@ -10,60 +15,79 @@ describe("PendingForkRegistry", () => {
|
|
|
10
15
|
vi.useRealTimers();
|
|
11
16
|
});
|
|
12
17
|
|
|
13
|
-
it("records and consumes a fork", () => {
|
|
18
|
+
it("records and consumes a fork by token", () => {
|
|
14
19
|
const reg = createPendingForkRegistry();
|
|
15
|
-
reg.recordFork("
|
|
16
|
-
const result = reg.consumeFork("
|
|
20
|
+
reg.recordFork("tok_a", "parent-1");
|
|
21
|
+
const result = reg.consumeFork("tok_a");
|
|
17
22
|
expect(result).toBe("parent-1");
|
|
18
23
|
});
|
|
19
24
|
|
|
20
25
|
it("returns undefined when no fork pending", () => {
|
|
21
26
|
const reg = createPendingForkRegistry();
|
|
22
|
-
expect(reg.consumeFork("
|
|
27
|
+
expect(reg.consumeFork("tok_unknown")).toBeUndefined();
|
|
23
28
|
});
|
|
24
29
|
|
|
25
30
|
it("consume clears the entry", () => {
|
|
26
31
|
const reg = createPendingForkRegistry();
|
|
27
|
-
reg.recordFork("
|
|
28
|
-
reg.consumeFork("
|
|
29
|
-
expect(reg.consumeFork("
|
|
32
|
+
reg.recordFork("tok_a", "parent-1");
|
|
33
|
+
reg.consumeFork("tok_a");
|
|
34
|
+
expect(reg.consumeFork("tok_a")).toBeUndefined();
|
|
30
35
|
});
|
|
31
36
|
|
|
32
37
|
it("expires after 30 seconds", () => {
|
|
33
38
|
const reg = createPendingForkRegistry();
|
|
34
|
-
reg.recordFork("
|
|
39
|
+
reg.recordFork("tok_a", "parent-1");
|
|
35
40
|
vi.advanceTimersByTime(30_001);
|
|
36
|
-
expect(reg.consumeFork("
|
|
41
|
+
expect(reg.consumeFork("tok_a")).toBeUndefined();
|
|
37
42
|
});
|
|
38
43
|
|
|
39
44
|
it("does not expire before 30 seconds", () => {
|
|
40
45
|
const reg = createPendingForkRegistry();
|
|
41
|
-
reg.recordFork("
|
|
46
|
+
reg.recordFork("tok_a", "parent-1");
|
|
42
47
|
vi.advanceTimersByTime(29_999);
|
|
43
|
-
expect(reg.consumeFork("
|
|
48
|
+
expect(reg.consumeFork("tok_a")).toBe("parent-1");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("re-recording with same token replaces parent", () => {
|
|
52
|
+
const reg = createPendingForkRegistry();
|
|
53
|
+
reg.recordFork("tok_a", "parent-1");
|
|
54
|
+
reg.recordFork("tok_a", "parent-2");
|
|
55
|
+
expect(reg.consumeFork("tok_a")).toBe("parent-2");
|
|
44
56
|
});
|
|
45
57
|
|
|
46
|
-
it("
|
|
58
|
+
it("different tokens are independent", () => {
|
|
47
59
|
const reg = createPendingForkRegistry();
|
|
48
|
-
reg.recordFork("
|
|
49
|
-
reg.recordFork("
|
|
50
|
-
expect(reg.consumeFork("
|
|
60
|
+
reg.recordFork("tok_a", "parent-a");
|
|
61
|
+
reg.recordFork("tok_b", "parent-b");
|
|
62
|
+
expect(reg.consumeFork("tok_a")).toBe("parent-a");
|
|
63
|
+
expect(reg.consumeFork("tok_b")).toBe("parent-b");
|
|
51
64
|
});
|
|
52
65
|
|
|
53
|
-
it("
|
|
66
|
+
it("multi-fork-in-same-cwd: each fork keyed by its own token, no overwrite", () => {
|
|
67
|
+
// Regression: the prior cwd-keyed registry would overwrite the first
|
|
68
|
+
// fork's parent when a second fork issued in the same cwd recorded
|
|
69
|
+
// its intent before the first's bridge registered. Token-keying
|
|
70
|
+
// makes the two intents independent.
|
|
54
71
|
const reg = createPendingForkRegistry();
|
|
55
|
-
reg.recordFork("
|
|
56
|
-
reg.recordFork("
|
|
57
|
-
|
|
58
|
-
expect(reg.consumeFork("
|
|
72
|
+
reg.recordFork("tok_fork1", "parent-A");
|
|
73
|
+
reg.recordFork("tok_fork2", "parent-B");
|
|
74
|
+
// Bridge connect order can be reversed:
|
|
75
|
+
expect(reg.consumeFork("tok_fork2")).toBe("parent-B");
|
|
76
|
+
expect(reg.consumeFork("tok_fork1")).toBe("parent-A");
|
|
59
77
|
});
|
|
60
78
|
|
|
61
79
|
it("dispose clears all timers", () => {
|
|
62
80
|
const reg = createPendingForkRegistry();
|
|
63
|
-
reg.recordFork("
|
|
64
|
-
reg.recordFork("
|
|
81
|
+
reg.recordFork("tok_a", "parent-a");
|
|
82
|
+
reg.recordFork("tok_b", "parent-b");
|
|
65
83
|
reg.dispose();
|
|
66
|
-
expect(reg.consumeFork("
|
|
67
|
-
expect(reg.consumeFork("
|
|
84
|
+
expect(reg.consumeFork("tok_a")).toBeUndefined();
|
|
85
|
+
expect(reg.consumeFork("tok_b")).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("empty token is rejected on record", () => {
|
|
89
|
+
const reg = createPendingForkRegistry();
|
|
90
|
+
reg.recordFork("", "parent-1");
|
|
91
|
+
expect(reg.consumeFork("")).toBeUndefined();
|
|
68
92
|
});
|
|
69
93
|
});
|