@imgly/codesign-mcp 0.1.0 → 0.1.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/assets/docs/handbook/SKILL.md +6 -0
- package/dist/assets/inline.generated.js +1 -1
- package/dist/assets/inline.generated.js.map +1 -1
- package/dist/cli/http.d.ts.map +1 -1
- package/dist/cli/http.js +4 -0
- package/dist/cli/http.js.map +1 -1
- package/dist/cli/stdio.d.ts.map +1 -1
- package/dist/cli/stdio.js +55 -18
- package/dist/cli/stdio.js.map +1 -1
- package/dist/engine/cesdk-adapter.d.ts +7 -1
- package/dist/engine/cesdk-adapter.d.ts.map +1 -1
- package/dist/engine/cesdk-adapter.js +7 -1
- package/dist/engine/cesdk-adapter.js.map +1 -1
- package/dist/engine/unsplash-source.d.ts +8 -0
- package/dist/engine/unsplash-source.d.ts.map +1 -0
- package/dist/engine/unsplash-source.js +77 -0
- package/dist/engine/unsplash-source.js.map +1 -0
- package/dist/http/server.d.ts.map +1 -1
- package/dist/http/server.js +0 -1
- package/dist/http/server.js.map +1 -1
- package/dist/server.d.ts +0 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js.map +1 -1
- package/dist/test/test/cli/redirect-console.test.d.ts +2 -0
- package/dist/test/test/cli/redirect-console.test.d.ts.map +1 -0
- package/dist/test/test/cli/redirect-console.test.js +47 -0
- package/dist/test/test/cli/redirect-console.test.js.map +1 -0
- package/dist/test/test/cli/resolve-license.test.d.ts +2 -0
- package/dist/test/test/cli/resolve-license.test.d.ts.map +1 -0
- package/dist/test/test/cli/resolve-license.test.js +20 -0
- package/dist/test/test/cli/resolve-license.test.js.map +1 -0
- package/dist/test/test/cli/stdio-license.test.d.ts +2 -0
- package/dist/test/test/cli/stdio-license.test.d.ts.map +1 -0
- package/dist/test/test/cli/stdio-license.test.js +19 -0
- package/dist/test/test/cli/stdio-license.test.js.map +1 -0
- package/dist/test/test/config.test.d.ts +2 -0
- package/dist/test/test/config.test.d.ts.map +1 -0
- package/dist/test/test/config.test.js +91 -0
- package/dist/test/test/config.test.js.map +1 -0
- package/dist/test/test/engine/asset-sources.test.d.ts +2 -0
- package/dist/test/test/engine/asset-sources.test.d.ts.map +1 -0
- package/dist/test/test/engine/asset-sources.test.js +100 -0
- package/dist/test/test/engine/asset-sources.test.js.map +1 -0
- package/dist/test/test/engine/mutex.test.d.ts +2 -0
- package/dist/test/test/engine/mutex.test.d.ts.map +1 -0
- package/dist/test/test/engine/mutex.test.js +63 -0
- package/dist/test/test/engine/mutex.test.js.map +1 -0
- package/dist/test/test/engine/session.test.js +15 -0
- package/dist/test/test/engine/session.test.js.map +1 -1
- package/dist/test/test/engine/unsplash-source.test.d.ts +2 -0
- package/dist/test/test/engine/unsplash-source.test.d.ts.map +1 -0
- package/dist/test/test/engine/unsplash-source.test.js +129 -0
- package/dist/test/test/engine/unsplash-source.test.js.map +1 -0
- package/dist/test/test/handbook-contract.test.js +8 -4
- package/dist/test/test/handbook-contract.test.js.map +1 -1
- package/dist/test/test/http/server.test.js +129 -0
- package/dist/test/test/http/server.test.js.map +1 -1
- package/dist/test/test/http/session-id-header.test.d.ts +2 -0
- package/dist/test/test/http/session-id-header.test.d.ts.map +1 -0
- package/dist/test/test/http/session-id-header.test.js +76 -0
- package/dist/test/test/http/session-id-header.test.js.map +1 -0
- package/dist/test/test/http/session-manager.test.js +14 -0
- package/dist/test/test/http/session-manager.test.js.map +1 -1
- package/dist/test/test/http/workspace-auth.test.d.ts +2 -0
- package/dist/test/test/http/workspace-auth.test.d.ts.map +1 -0
- package/dist/test/test/http/workspace-auth.test.js +41 -0
- package/dist/test/test/http/workspace-auth.test.js.map +1 -0
- package/dist/test/test/metrics/mount.test.d.ts +2 -0
- package/dist/test/test/metrics/mount.test.d.ts.map +1 -0
- package/dist/test/test/metrics/mount.test.js +59 -0
- package/dist/test/test/metrics/mount.test.js.map +1 -0
- package/dist/test/test/metrics/recorder.test.d.ts +2 -0
- package/dist/test/test/metrics/recorder.test.d.ts.map +1 -0
- package/dist/test/test/metrics/recorder.test.js +118 -0
- package/dist/test/test/metrics/recorder.test.js.map +1 -0
- package/dist/test/test/revisions/store.test.js +81 -11
- package/dist/test/test/revisions/store.test.js.map +1 -1
- package/dist/test/test/server.metrics.test.d.ts +2 -0
- package/dist/test/test/server.metrics.test.d.ts.map +1 -0
- package/dist/test/test/server.metrics.test.js +87 -0
- package/dist/test/test/server.metrics.test.js.map +1 -0
- package/dist/test/test/server.test.js +33 -4
- package/dist/test/test/server.test.js.map +1 -1
- package/dist/test/test/tools/asset-search.test.d.ts +2 -0
- package/dist/test/test/tools/asset-search.test.d.ts.map +1 -0
- package/dist/test/test/tools/asset-search.test.js +121 -0
- package/dist/test/test/tools/asset-search.test.js.map +1 -0
- package/dist/test/test/tools/edit.test.js +71 -9
- package/dist/test/test/tools/edit.test.js.map +1 -1
- package/dist/test/test/tools/export.test.js +60 -30
- package/dist/test/test/tools/export.test.js.map +1 -1
- package/dist/test/test/tools/index.test.d.ts +2 -0
- package/dist/test/test/tools/index.test.d.ts.map +1 -0
- package/dist/test/test/tools/index.test.js +55 -0
- package/dist/test/test/tools/index.test.js.map +1 -0
- package/dist/test/test/tools/metrics.test.d.ts +2 -0
- package/dist/test/test/tools/metrics.test.d.ts.map +1 -0
- package/dist/test/test/tools/metrics.test.js +29 -0
- package/dist/test/test/tools/metrics.test.js.map +1 -0
- package/dist/test/test/tools/preview.test.js +6 -4
- package/dist/test/test/tools/preview.test.js.map +1 -1
- package/dist/test/test/tools/rename.test.js +1 -2
- package/dist/test/test/tools/rename.test.js.map +1 -1
- package/dist/test/test/tools/save.test.d.ts +2 -0
- package/dist/test/test/tools/save.test.d.ts.map +1 -0
- package/dist/test/test/tools/save.test.js +137 -0
- package/dist/test/test/tools/save.test.js.map +1 -0
- package/dist/test/test/viewer/router.test.js +56 -0
- package/dist/test/test/viewer/router.test.js.map +1 -1
- package/dist/test/test/viewer/session-mount.test.d.ts +2 -0
- package/dist/test/test/viewer/session-mount.test.d.ts.map +1 -0
- package/dist/test/test/viewer/session-mount.test.js +52 -0
- package/dist/test/test/viewer/session-mount.test.js.map +1 -0
- package/dist/test/test/viewer/url-builder.test.js +76 -9
- package/dist/test/test/viewer/url-builder.test.js.map +1 -1
- package/dist/test/test/workspace/s3.test.d.ts +2 -0
- package/dist/test/test/workspace/s3.test.d.ts.map +1 -0
- package/dist/test/test/workspace/s3.test.js +153 -0
- package/dist/test/test/workspace/s3.test.js.map +1 -0
- package/dist/test/test/workspace/scoped.test.d.ts +2 -0
- package/dist/test/test/workspace/scoped.test.d.ts.map +1 -0
- package/dist/test/test/workspace/scoped.test.js +59 -0
- package/dist/test/test/workspace/scoped.test.js.map +1 -0
- package/dist/test/tsconfig.test.tsbuildinfo +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +14 -4
- package/dist/tools/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { createCodesignHttpServer } from '../../src/http/server.js';
|
|
4
|
+
// The transport echoes the session id it assigned in the `mcp-session-id`
|
|
5
|
+
// response header. We assert that, with `trustedSessionIdHeader` configured,
|
|
6
|
+
// an inbound header value becomes the session id verbatim — and without it,
|
|
7
|
+
// the server still mints its own (a normal uuid, not our injected value).
|
|
8
|
+
const INIT_BODY = {
|
|
9
|
+
jsonrpc: '2.0',
|
|
10
|
+
id: 1,
|
|
11
|
+
method: 'initialize',
|
|
12
|
+
params: {
|
|
13
|
+
protocolVersion: '2024-11-05',
|
|
14
|
+
capabilities: {},
|
|
15
|
+
clientInfo: { name: 'test', version: '0.0.0' }
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
describe('trustedSessionIdHeader', () => {
|
|
19
|
+
it('uses the inbound header value as the session id on initialize', async () => {
|
|
20
|
+
const app = await createCodesignHttpServer({
|
|
21
|
+
assets: {},
|
|
22
|
+
maxSessions: 4,
|
|
23
|
+
idleTimeoutMs: 60_000,
|
|
24
|
+
trustedSessionIdHeader: 'x-mcp-session-id',
|
|
25
|
+
resolveWorkspaceId: (_req, sid) => `s/${sid}`,
|
|
26
|
+
createSession: async () => ({ engine: {}, tools: [] })
|
|
27
|
+
});
|
|
28
|
+
const res = await request(app)
|
|
29
|
+
.post('/mcp')
|
|
30
|
+
.set('accept', 'application/json, text/event-stream')
|
|
31
|
+
.set('content-type', 'application/json')
|
|
32
|
+
.set('x-mcp-session-id', 'worker-minted-123')
|
|
33
|
+
.send(INIT_BODY);
|
|
34
|
+
expect(res.headers['mcp-session-id']).toBe('worker-minted-123');
|
|
35
|
+
await app.shutdown();
|
|
36
|
+
});
|
|
37
|
+
it('mints its own id when the header is absent', async () => {
|
|
38
|
+
const app = await createCodesignHttpServer({
|
|
39
|
+
assets: {},
|
|
40
|
+
maxSessions: 4,
|
|
41
|
+
idleTimeoutMs: 60_000,
|
|
42
|
+
trustedSessionIdHeader: 'x-mcp-session-id',
|
|
43
|
+
resolveWorkspaceId: (_req, sid) => `s/${sid}`,
|
|
44
|
+
createSession: async () => ({ engine: {}, tools: [] })
|
|
45
|
+
});
|
|
46
|
+
const res = await request(app)
|
|
47
|
+
.post('/mcp')
|
|
48
|
+
.set('accept', 'application/json, text/event-stream')
|
|
49
|
+
.set('content-type', 'application/json')
|
|
50
|
+
.send(INIT_BODY);
|
|
51
|
+
expect(res.headers['mcp-session-id']).toBeDefined();
|
|
52
|
+
expect(res.headers['mcp-session-id']).not.toBe('worker-minted-123');
|
|
53
|
+
expect(res.headers['mcp-session-id']).toMatch(/^[0-9a-f-]{36}$/i);
|
|
54
|
+
await app.shutdown();
|
|
55
|
+
});
|
|
56
|
+
it('mints its own id when the header is present but empty', async () => {
|
|
57
|
+
const app = await createCodesignHttpServer({
|
|
58
|
+
assets: {},
|
|
59
|
+
maxSessions: 4,
|
|
60
|
+
idleTimeoutMs: 60_000,
|
|
61
|
+
trustedSessionIdHeader: 'x-mcp-session-id',
|
|
62
|
+
resolveWorkspaceId: (_req, sid) => `s/${sid}`,
|
|
63
|
+
createSession: async () => ({ engine: {}, tools: [] })
|
|
64
|
+
});
|
|
65
|
+
const res = await request(app)
|
|
66
|
+
.post('/mcp')
|
|
67
|
+
.set('accept', 'application/json, text/event-stream')
|
|
68
|
+
.set('content-type', 'application/json')
|
|
69
|
+
.set('x-mcp-session-id', '')
|
|
70
|
+
.send(INIT_BODY);
|
|
71
|
+
expect(res.headers['mcp-session-id']).toBeDefined();
|
|
72
|
+
expect(res.headers['mcp-session-id']).toMatch(/^[0-9a-f-]{36}$/i);
|
|
73
|
+
await app.shutdown();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
//# sourceMappingURL=session-id-header.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-id-header.test.js","sourceRoot":"","sources":["../../../../test/http/session-id-header.test.ts"],"names":[],"mappings":"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","sourcesContent":["import request from 'supertest';\nimport { describe, expect, it } from 'vitest';\nimport { createCodesignHttpServer } from '../../src/http/server.js';\n\n// The transport echoes the session id it assigned in the `mcp-session-id`\n// response header. We assert that, with `trustedSessionIdHeader` configured,\n// an inbound header value becomes the session id verbatim — and without it,\n// the server still mints its own (a normal uuid, not our injected value).\nconst INIT_BODY = {\n jsonrpc: '2.0',\n id: 1,\n method: 'initialize',\n params: {\n protocolVersion: '2024-11-05',\n capabilities: {},\n clientInfo: { name: 'test', version: '0.0.0' }\n }\n};\n\ndescribe('trustedSessionIdHeader', () => {\n it('uses the inbound header value as the session id on initialize', async () => {\n const app = await createCodesignHttpServer({\n assets: {},\n maxSessions: 4,\n idleTimeoutMs: 60_000,\n trustedSessionIdHeader: 'x-mcp-session-id',\n resolveWorkspaceId: (_req, sid) => `s/${sid}`,\n createSession: async () => ({ engine: {}, tools: [] })\n });\n const res = await request(app)\n .post('/mcp')\n .set('accept', 'application/json, text/event-stream')\n .set('content-type', 'application/json')\n .set('x-mcp-session-id', 'worker-minted-123')\n .send(INIT_BODY);\n expect(res.headers['mcp-session-id']).toBe('worker-minted-123');\n await app.shutdown();\n });\n\n it('mints its own id when the header is absent', async () => {\n const app = await createCodesignHttpServer({\n assets: {},\n maxSessions: 4,\n idleTimeoutMs: 60_000,\n trustedSessionIdHeader: 'x-mcp-session-id',\n resolveWorkspaceId: (_req, sid) => `s/${sid}`,\n createSession: async () => ({ engine: {}, tools: [] })\n });\n const res = await request(app)\n .post('/mcp')\n .set('accept', 'application/json, text/event-stream')\n .set('content-type', 'application/json')\n .send(INIT_BODY);\n expect(res.headers['mcp-session-id']).toBeDefined();\n expect(res.headers['mcp-session-id']).not.toBe('worker-minted-123');\n expect(res.headers['mcp-session-id']).toMatch(/^[0-9a-f-]{36}$/i);\n await app.shutdown();\n });\n\n it('mints its own id when the header is present but empty', async () => {\n const app = await createCodesignHttpServer({\n assets: {},\n maxSessions: 4,\n idleTimeoutMs: 60_000,\n trustedSessionIdHeader: 'x-mcp-session-id',\n resolveWorkspaceId: (_req, sid) => `s/${sid}`,\n createSession: async () => ({ engine: {}, tools: [] })\n });\n const res = await request(app)\n .post('/mcp')\n .set('accept', 'application/json, text/event-stream')\n .set('content-type', 'application/json')\n .set('x-mcp-session-id', '')\n .send(INIT_BODY);\n expect(res.headers['mcp-session-id']).toBeDefined();\n expect(res.headers['mcp-session-id']).toMatch(/^[0-9a-f-]{36}$/i);\n await app.shutdown();\n });\n});\n"]}
|
|
@@ -79,5 +79,19 @@ describe('SessionManager', () => {
|
|
|
79
79
|
expect(a.engine.disposed).toBe(true);
|
|
80
80
|
expect(b.engine.disposed).toBe(true);
|
|
81
81
|
});
|
|
82
|
+
it('forwards a context object to the create callback', async () => {
|
|
83
|
+
const seen = [];
|
|
84
|
+
const mgr = new SessionManager({
|
|
85
|
+
maxSessions: 10,
|
|
86
|
+
idleTimeoutMs: 60_000,
|
|
87
|
+
create: (ctx) => {
|
|
88
|
+
seen.push(ctx?.workspaceId);
|
|
89
|
+
return { id: ctx?.workspaceId ?? 'none' };
|
|
90
|
+
},
|
|
91
|
+
destroy: () => { }
|
|
92
|
+
});
|
|
93
|
+
await mgr.create({ workspaceId: 'u/a' });
|
|
94
|
+
expect(seen).toEqual(['u/a']);
|
|
95
|
+
});
|
|
82
96
|
});
|
|
83
97
|
//# sourceMappingURL=session-manager.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-manager.test.js","sourceRoot":"","sources":["../../../../test/http/session-manager.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport {\n SessionCapExceededError,\n SessionManager,\n SessionNotFoundError\n} from '../../src/http/session-manager.js';\n\ninterface FakeEngine {\n id: number;\n disposed: boolean;\n}\n\ninterface FakeSession {\n id: string;\n engine: FakeEngine;\n}\n\ndescribe('SessionManager', () => {\n let nextEngineId: number;\n let createdEngines: FakeEngine[];\n let mgr: SessionManager<FakeSession>;\n\n beforeEach(() => {\n vi.useFakeTimers();\n vi.setSystemTime(new Date('2026-05-09T00:00:00Z'));\n nextEngineId = 0;\n createdEngines = [];\n mgr = new SessionManager<FakeSession>({\n maxSessions: 3,\n idleTimeoutMs: 60_000,\n create: async () => {\n const engine: FakeEngine = { id: nextEngineId++, disposed: false };\n createdEngines.push(engine);\n return { id: `s-${engine.id}`, engine };\n },\n destroy: async (s) => {\n s.engine.disposed = true;\n }\n });\n });\n\n afterEach(() => {\n vi.useRealTimers();\n });\n\n it('create() returns a fresh session and registers it under its id', async () => {\n const s = await mgr.create();\n expect(s.id).toBe('s-0');\n expect(s.engine.id).toBe(0);\n expect(mgr.get(s.id)).toBe(s);\n });\n\n it('get() returns undefined for unknown ids', () => {\n expect(mgr.get('nope')).toBeUndefined();\n });\n\n it('touch() updates lastActivityAt so the session survives idle eviction', async () => {\n const a = await mgr.create();\n vi.advanceTimersByTime(50_000);\n mgr.touch(a.id);\n vi.advanceTimersByTime(50_000); // total 100s; without touch a would be evicted\n await mgr.evictIdle();\n expect(mgr.get(a.id)).toBe(a);\n expect(a.engine.disposed).toBe(false);\n });\n\n it('evictIdle() destroys sessions older than idleTimeoutMs', async () => {\n const a = await mgr.create();\n const b = await mgr.create();\n vi.advanceTimersByTime(30_000);\n mgr.touch(b.id); // bump b only\n vi.advanceTimersByTime(40_000); // a is now 70s idle, b only 40s\n await mgr.evictIdle();\n expect(mgr.get(a.id)).toBeUndefined();\n expect(a.engine.disposed).toBe(true);\n expect(mgr.get(b.id)).toBe(b);\n expect(b.engine.disposed).toBe(false);\n });\n\n it('create() throws SessionCapExceededError once maxSessions is reached', async () => {\n await mgr.create();\n await mgr.create();\n await mgr.create();\n await expect(mgr.create()).rejects.toBeInstanceOf(SessionCapExceededError);\n });\n\n it('delete() destroys a session and removes it', async () => {\n const a = await mgr.create();\n await mgr.delete(a.id);\n expect(mgr.get(a.id)).toBeUndefined();\n expect(a.engine.disposed).toBe(true);\n });\n\n it('delete() of an unknown id throws SessionNotFoundError', async () => {\n await expect(mgr.delete('nope')).rejects.toBeInstanceOf(\n SessionNotFoundError\n );\n });\n\n it('destroyAll() shuts down every active session', async () => {\n const a = await mgr.create();\n const b = await mgr.create();\n await mgr.destroyAll();\n expect(mgr.get(a.id)).toBeUndefined();\n expect(mgr.get(b.id)).toBeUndefined();\n expect(a.engine.disposed).toBe(true);\n expect(b.engine.disposed).toBe(true);\n });\n});\n"]}
|
|
1
|
+
{"version":3,"file":"session-manager.test.js","sourceRoot":"","sources":["../../../../test/http/session-manager.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport {\n SessionCapExceededError,\n SessionManager,\n SessionNotFoundError\n} from '../../src/http/session-manager.js';\n\ninterface FakeEngine {\n id: number;\n disposed: boolean;\n}\n\ninterface FakeSession {\n id: string;\n engine: FakeEngine;\n}\n\ndescribe('SessionManager', () => {\n let nextEngineId: number;\n let createdEngines: FakeEngine[];\n let mgr: SessionManager<FakeSession>;\n\n beforeEach(() => {\n vi.useFakeTimers();\n vi.setSystemTime(new Date('2026-05-09T00:00:00Z'));\n nextEngineId = 0;\n createdEngines = [];\n mgr = new SessionManager<FakeSession>({\n maxSessions: 3,\n idleTimeoutMs: 60_000,\n create: async () => {\n const engine: FakeEngine = { id: nextEngineId++, disposed: false };\n createdEngines.push(engine);\n return { id: `s-${engine.id}`, engine };\n },\n destroy: async (s) => {\n s.engine.disposed = true;\n }\n });\n });\n\n afterEach(() => {\n vi.useRealTimers();\n });\n\n it('create() returns a fresh session and registers it under its id', async () => {\n const s = await mgr.create();\n expect(s.id).toBe('s-0');\n expect(s.engine.id).toBe(0);\n expect(mgr.get(s.id)).toBe(s);\n });\n\n it('get() returns undefined for unknown ids', () => {\n expect(mgr.get('nope')).toBeUndefined();\n });\n\n it('touch() updates lastActivityAt so the session survives idle eviction', async () => {\n const a = await mgr.create();\n vi.advanceTimersByTime(50_000);\n mgr.touch(a.id);\n vi.advanceTimersByTime(50_000); // total 100s; without touch a would be evicted\n await mgr.evictIdle();\n expect(mgr.get(a.id)).toBe(a);\n expect(a.engine.disposed).toBe(false);\n });\n\n it('evictIdle() destroys sessions older than idleTimeoutMs', async () => {\n const a = await mgr.create();\n const b = await mgr.create();\n vi.advanceTimersByTime(30_000);\n mgr.touch(b.id); // bump b only\n vi.advanceTimersByTime(40_000); // a is now 70s idle, b only 40s\n await mgr.evictIdle();\n expect(mgr.get(a.id)).toBeUndefined();\n expect(a.engine.disposed).toBe(true);\n expect(mgr.get(b.id)).toBe(b);\n expect(b.engine.disposed).toBe(false);\n });\n\n it('create() throws SessionCapExceededError once maxSessions is reached', async () => {\n await mgr.create();\n await mgr.create();\n await mgr.create();\n await expect(mgr.create()).rejects.toBeInstanceOf(SessionCapExceededError);\n });\n\n it('delete() destroys a session and removes it', async () => {\n const a = await mgr.create();\n await mgr.delete(a.id);\n expect(mgr.get(a.id)).toBeUndefined();\n expect(a.engine.disposed).toBe(true);\n });\n\n it('delete() of an unknown id throws SessionNotFoundError', async () => {\n await expect(mgr.delete('nope')).rejects.toBeInstanceOf(\n SessionNotFoundError\n );\n });\n\n it('destroyAll() shuts down every active session', async () => {\n const a = await mgr.create();\n const b = await mgr.create();\n await mgr.destroyAll();\n expect(mgr.get(a.id)).toBeUndefined();\n expect(mgr.get(b.id)).toBeUndefined();\n expect(a.engine.disposed).toBe(true);\n expect(b.engine.disposed).toBe(true);\n });\n\n it('forwards a context object to the create callback', async () => {\n const seen: Array<string | undefined> = [];\n const mgr = new SessionManager<{ id: string }, { workspaceId: string }>({\n maxSessions: 10,\n idleTimeoutMs: 60_000,\n create: (ctx) => {\n seen.push(ctx?.workspaceId);\n return { id: ctx?.workspaceId ?? 'none' };\n },\n destroy: () => {}\n });\n await mgr.create({ workspaceId: 'u/a' });\n expect(seen).toEqual(['u/a']);\n });\n});\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-auth.test.d.ts","sourceRoot":"","sources":["../../../../test/http/workspace-auth.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { makeWorkspaceResolver, WorkspaceAuthError } from '../../src/http/workspace-auth.js';
|
|
3
|
+
function cfg(partial) {
|
|
4
|
+
return {
|
|
5
|
+
cesdkLicense: 'x',
|
|
6
|
+
buildId: 'test',
|
|
7
|
+
port: 8080,
|
|
8
|
+
maxEngines: 8,
|
|
9
|
+
idleTimeoutMs: 900_000,
|
|
10
|
+
logLevel: 'info',
|
|
11
|
+
metricsEnabled: false,
|
|
12
|
+
workspace: { backend: 'local', dir: '/data' },
|
|
13
|
+
auth: { mode: 'header', header: 'X-Workspace-Id', ...partial },
|
|
14
|
+
gateway: {}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function fakeReq(headers) {
|
|
18
|
+
return {
|
|
19
|
+
header: (name) => headers[name.toLowerCase()]
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
describe('makeWorkspaceResolver', () => {
|
|
23
|
+
it('header mode returns the trusted header value (opaque, unmodified)', () => {
|
|
24
|
+
const resolve = makeWorkspaceResolver(cfg({ mode: 'header', header: 'X-Workspace-Id' }));
|
|
25
|
+
const req = fakeReq({ 'x-workspace-id': 'u/usr_8842' });
|
|
26
|
+
expect(resolve(req, 'sess-1')).toBe('u/usr_8842');
|
|
27
|
+
});
|
|
28
|
+
it('header mode throws WorkspaceAuthError when the header is absent', () => {
|
|
29
|
+
const resolve = makeWorkspaceResolver(cfg({ mode: 'header' }));
|
|
30
|
+
expect(() => resolve(fakeReq({}), 'sess-1')).toThrow(WorkspaceAuthError);
|
|
31
|
+
});
|
|
32
|
+
it('session mode derives the ephemeral s/<sessionId> id', () => {
|
|
33
|
+
const resolve = makeWorkspaceResolver(cfg({ mode: 'session' }));
|
|
34
|
+
expect(resolve(fakeReq({}), 'sess-42')).toBe('s/sess-42');
|
|
35
|
+
});
|
|
36
|
+
it('single mode returns the fixed id', () => {
|
|
37
|
+
const resolve = makeWorkspaceResolver(cfg({ mode: 'single', fixedWorkspaceId: 'acme' }));
|
|
38
|
+
expect(resolve(fakeReq({}), 'sess-1')).toBe('acme');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
//# sourceMappingURL=workspace-auth.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-auth.test.js","sourceRoot":"","sources":["../../../../test/http/workspace-auth.test.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAE9C,OAAO,EACL,qBAAqB,EACrB,kBAAkB,EACnB,MAAM,kCAAkC,CAAC;AAE1C,SAAS,GAAG,CAAC,OAAsC;IACjD,OAAO;QACL,YAAY,EAAE,GAAG;QACjB,OAAO,EAAE,MAAM;QACf,IAAI,EAAE,IAAI;QACV,UAAU,EAAE,CAAC;QACb,aAAa,EAAE,OAAO;QACtB,QAAQ,EAAE,MAAM;QAChB,cAAc,EAAE,KAAK;QACrB,SAAS,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE;QAC7C,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,gBAAgB,EAAE,GAAG,OAAO,EAAE;QAC9D,OAAO,EAAE,EAAE;KACZ,CAAC;AACJ,CAAC;AAED,SAAS,OAAO,CAAC,OAA+B;IAC9C,OAAO;QACL,MAAM,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;KAChC,CAAC;AAC1B,CAAC;AAED,QAAQ,CAAC,uBAAuB,EAAE,GAAG,EAAE;IACrC,EAAE,CAAC,mEAAmE,EAAE,GAAG,EAAE;QAC3E,MAAM,OAAO,GAAG,qBAAqB,CACnC,GAAG,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAClD,CAAC;QACF,MAAM,GAAG,GAAG,OAAO,CAAC,EAAE,gBAAgB,EAAE,YAAY,EAAE,CAAC,CAAC;QACxD,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;IACpD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iEAAiE,EAAE,GAAG,EAAE;QACzE,MAAM,OAAO,GAAG,qBAAqB,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC;QAC/D,MAAM,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAC3E,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qDAAqD,EAAE,GAAG,EAAE;QAC7D,MAAM,OAAO,GAAG,qBAAqB,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC;QAChE,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;IAC5D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kCAAkC,EAAE,GAAG,EAAE;QAC1C,MAAM,OAAO,GAAG,qBAAqB,CACnC,GAAG,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAClD,CAAC;QACF,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACtD,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC","sourcesContent":["import type { Request } from 'express';\nimport { describe, expect, it } from 'vitest';\nimport type { ServerConfig } from '../../src/config.js';\nimport {\n makeWorkspaceResolver,\n WorkspaceAuthError\n} from '../../src/http/workspace-auth.js';\n\nfunction cfg(partial: Partial<ServerConfig['auth']>): ServerConfig {\n return {\n cesdkLicense: 'x',\n buildId: 'test',\n port: 8080,\n maxEngines: 8,\n idleTimeoutMs: 900_000,\n logLevel: 'info',\n metricsEnabled: false,\n workspace: { backend: 'local', dir: '/data' },\n auth: { mode: 'header', header: 'X-Workspace-Id', ...partial },\n gateway: {}\n };\n}\n\nfunction fakeReq(headers: Record<string, string>): Request {\n return {\n header: (name: string) => headers[name.toLowerCase()]\n } as unknown as Request;\n}\n\ndescribe('makeWorkspaceResolver', () => {\n it('header mode returns the trusted header value (opaque, unmodified)', () => {\n const resolve = makeWorkspaceResolver(\n cfg({ mode: 'header', header: 'X-Workspace-Id' })\n );\n const req = fakeReq({ 'x-workspace-id': 'u/usr_8842' });\n expect(resolve(req, 'sess-1')).toBe('u/usr_8842');\n });\n\n it('header mode throws WorkspaceAuthError when the header is absent', () => {\n const resolve = makeWorkspaceResolver(cfg({ mode: 'header' }));\n expect(() => resolve(fakeReq({}), 'sess-1')).toThrow(WorkspaceAuthError);\n });\n\n it('session mode derives the ephemeral s/<sessionId> id', () => {\n const resolve = makeWorkspaceResolver(cfg({ mode: 'session' }));\n expect(resolve(fakeReq({}), 'sess-42')).toBe('s/sess-42');\n });\n\n it('single mode returns the fixed id', () => {\n const resolve = makeWorkspaceResolver(\n cfg({ mode: 'single', fixedWorkspaceId: 'acme' })\n );\n expect(resolve(fakeReq({}), 'sess-1')).toBe('acme');\n });\n});\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mount.test.d.ts","sourceRoot":"","sources":["../../../../test/metrics/mount.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { mountMetricsEndpoints } from '../../src/metrics/mount.js';
|
|
5
|
+
import { MetricsRecorder } from '../../src/metrics/recorder.js';
|
|
6
|
+
describe('mountMetricsEndpoints', () => {
|
|
7
|
+
it('GET /metrics returns the merged snapshot', async () => {
|
|
8
|
+
const recorder = new MetricsRecorder();
|
|
9
|
+
recorder.record({ tool: 'edit', durationMs: 12, ok: true });
|
|
10
|
+
recorder.sampleMemory();
|
|
11
|
+
const app = express();
|
|
12
|
+
mountMetricsEndpoints(app, {
|
|
13
|
+
recorder,
|
|
14
|
+
maxSessions: 8,
|
|
15
|
+
activeSessions: () => 2
|
|
16
|
+
});
|
|
17
|
+
const res = await request(app).get('/metrics');
|
|
18
|
+
expect(res.status).toBe(200);
|
|
19
|
+
expect(res.body.activeSessions).toBe(2);
|
|
20
|
+
expect(res.body.maxSessions).toBe(8);
|
|
21
|
+
expect(typeof res.body.uptimeMs).toBe('number');
|
|
22
|
+
expect(res.body.tools.find((t) => t.tool === 'edit')).toBeDefined();
|
|
23
|
+
expect(res.body.memory).not.toBeNull();
|
|
24
|
+
expect(Array.isArray(res.body.recent)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
it('reads activeSessions live at request time', async () => {
|
|
27
|
+
const recorder = new MetricsRecorder();
|
|
28
|
+
let live = 0;
|
|
29
|
+
const app = express();
|
|
30
|
+
mountMetricsEndpoints(app, {
|
|
31
|
+
recorder,
|
|
32
|
+
maxSessions: 4,
|
|
33
|
+
activeSessions: () => live
|
|
34
|
+
});
|
|
35
|
+
live = 3;
|
|
36
|
+
const res = await request(app).get('/metrics');
|
|
37
|
+
expect(res.body.activeSessions).toBe(3);
|
|
38
|
+
});
|
|
39
|
+
it('serves the dashboard SPA at /dashboard/ with the metrics URL injected', async () => {
|
|
40
|
+
const recorder = new MetricsRecorder();
|
|
41
|
+
const app = express();
|
|
42
|
+
mountMetricsEndpoints(app, {
|
|
43
|
+
recorder,
|
|
44
|
+
maxSessions: 8,
|
|
45
|
+
activeSessions: () => 0
|
|
46
|
+
});
|
|
47
|
+
const res = await request(app).get('/dashboard/');
|
|
48
|
+
expect(res.status).toBe(200);
|
|
49
|
+
expect(res.type).toBe('text/html');
|
|
50
|
+
expect(res.text).toContain('/metrics');
|
|
51
|
+
expect(res.text).not.toContain('{{METRICS_URL}}');
|
|
52
|
+
});
|
|
53
|
+
it('does not mount /metrics when the mounter is not called (gating)', async () => {
|
|
54
|
+
const app = express();
|
|
55
|
+
const res = await request(app).get('/metrics');
|
|
56
|
+
expect(res.status).toBe(404);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
//# sourceMappingURL=mount.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mount.test.js","sourceRoot":"","sources":["../../../../test/metrics/mount.test.ts"],"names":[],"mappings":"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","sourcesContent":["import express from 'express';\nimport request from 'supertest';\nimport { describe, expect, it } from 'vitest';\nimport { mountMetricsEndpoints } from '../../src/metrics/mount.js';\nimport { MetricsRecorder } from '../../src/metrics/recorder.js';\n\ndescribe('mountMetricsEndpoints', () => {\n it('GET /metrics returns the merged snapshot', async () => {\n const recorder = new MetricsRecorder();\n recorder.record({ tool: 'edit', durationMs: 12, ok: true });\n recorder.sampleMemory();\n const app = express();\n mountMetricsEndpoints(app, {\n recorder,\n maxSessions: 8,\n activeSessions: () => 2\n });\n const res = await request(app).get('/metrics');\n expect(res.status).toBe(200);\n expect(res.body.activeSessions).toBe(2);\n expect(res.body.maxSessions).toBe(8);\n expect(typeof res.body.uptimeMs).toBe('number');\n expect(\n res.body.tools.find((t: { tool: string }) => t.tool === 'edit')\n ).toBeDefined();\n expect(res.body.memory).not.toBeNull();\n expect(Array.isArray(res.body.recent)).toBe(true);\n });\n\n it('reads activeSessions live at request time', async () => {\n const recorder = new MetricsRecorder();\n let live = 0;\n const app = express();\n mountMetricsEndpoints(app, {\n recorder,\n maxSessions: 4,\n activeSessions: () => live\n });\n live = 3;\n const res = await request(app).get('/metrics');\n expect(res.body.activeSessions).toBe(3);\n });\n\n it('serves the dashboard SPA at /dashboard/ with the metrics URL injected', async () => {\n const recorder = new MetricsRecorder();\n const app = express();\n mountMetricsEndpoints(app, {\n recorder,\n maxSessions: 8,\n activeSessions: () => 0\n });\n const res = await request(app).get('/dashboard/');\n expect(res.status).toBe(200);\n expect(res.type).toBe('text/html');\n expect(res.text).toContain('/metrics');\n expect(res.text).not.toContain('{{METRICS_URL}}');\n });\n\n it('does not mount /metrics when the mounter is not called (gating)', async () => {\n const app = express();\n const res = await request(app).get('/metrics');\n expect(res.status).toBe(404);\n });\n});\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder.test.d.ts","sourceRoot":"","sources":["../../../../test/metrics/recorder.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { MetricsRecorder } from '../../src/metrics/recorder.js';
|
|
3
|
+
describe('MetricsRecorder.record + snapshot', () => {
|
|
4
|
+
it('aggregates count/errorCount and min/max/mean from the sample', () => {
|
|
5
|
+
const r = new MetricsRecorder();
|
|
6
|
+
r.record({ tool: 'edit', durationMs: 10, ok: true });
|
|
7
|
+
r.record({ tool: 'edit', durationMs: 30, ok: false });
|
|
8
|
+
r.record({ tool: 'edit', durationMs: 20, ok: true });
|
|
9
|
+
const edit = r.snapshot().tools.find((t) => t.tool === 'edit');
|
|
10
|
+
expect(edit).toBeDefined();
|
|
11
|
+
expect(edit.count).toBe(3);
|
|
12
|
+
expect(edit.errorCount).toBe(1);
|
|
13
|
+
expect(edit.min).toBe(10);
|
|
14
|
+
expect(edit.max).toBe(30);
|
|
15
|
+
expect(edit.mean).toBe(20);
|
|
16
|
+
});
|
|
17
|
+
it('computes p50/p95 via nearest-rank over the sample', () => {
|
|
18
|
+
const r = new MetricsRecorder();
|
|
19
|
+
for (const d of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100])
|
|
20
|
+
r.record({ tool: 'x', durationMs: d, ok: true });
|
|
21
|
+
const x = r.snapshot().tools.find((t) => t.tool === 'x');
|
|
22
|
+
// nearest-rank: p50 → idx ceil(0.5*10)-1 = 4 → 50; p95 → idx 9 → 100
|
|
23
|
+
expect(x.p50).toBe(50);
|
|
24
|
+
expect(x.p95).toBe(100);
|
|
25
|
+
});
|
|
26
|
+
it('separates aggregates by tool name', () => {
|
|
27
|
+
const r = new MetricsRecorder();
|
|
28
|
+
r.record({ tool: 'edit', durationMs: 5, ok: true });
|
|
29
|
+
r.record({ tool: 'export', durationMs: 7, ok: true });
|
|
30
|
+
expect(r
|
|
31
|
+
.snapshot()
|
|
32
|
+
.tools.map((t) => t.tool)
|
|
33
|
+
.sort()).toEqual(['edit', 'export']);
|
|
34
|
+
});
|
|
35
|
+
it('evicts oldest recent records beyond recentLimit', () => {
|
|
36
|
+
const r = new MetricsRecorder({ recentLimit: 3 });
|
|
37
|
+
for (let i = 0; i < 5; i++)
|
|
38
|
+
r.record({ tool: 't', durationMs: i, ok: true });
|
|
39
|
+
const recent = r.snapshot().recent;
|
|
40
|
+
expect(recent).toHaveLength(3);
|
|
41
|
+
// newest-last ring: durations 2,3,4 survive
|
|
42
|
+
expect(recent.map((x) => x.durationMs)).toEqual([2, 3, 4]);
|
|
43
|
+
});
|
|
44
|
+
it('bounds the per-tool duration sample to sampleSize (most-recent wins)', () => {
|
|
45
|
+
const r = new MetricsRecorder({ sampleSize: 2 });
|
|
46
|
+
r.record({ tool: 't', durationMs: 1000, ok: true });
|
|
47
|
+
r.record({ tool: 't', durationMs: 1, ok: true });
|
|
48
|
+
r.record({ tool: 't', durationMs: 2, ok: true });
|
|
49
|
+
const t = r.snapshot().tools.find((x) => x.tool === 't');
|
|
50
|
+
// count still counts every call…
|
|
51
|
+
expect(t.count).toBe(3);
|
|
52
|
+
// …but min/max/percentiles come from the bounded sample [1, 2]
|
|
53
|
+
expect(t.min).toBe(1);
|
|
54
|
+
expect(t.max).toBe(2);
|
|
55
|
+
expect(t.p95).toBe(2);
|
|
56
|
+
expect(t.p50).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
it('snapshot has the documented shape', () => {
|
|
59
|
+
const r = new MetricsRecorder();
|
|
60
|
+
r.record({ tool: 't', durationMs: 1, ok: true });
|
|
61
|
+
const snap = r.snapshot();
|
|
62
|
+
expect(snap).toHaveProperty('tools');
|
|
63
|
+
expect(snap).toHaveProperty('recent');
|
|
64
|
+
expect(snap).toHaveProperty('memory');
|
|
65
|
+
expect(Array.isArray(snap.tools)).toBe(true);
|
|
66
|
+
expect(Array.isArray(snap.recent)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
it('recent records carry tool, durationMs, ok, ts', () => {
|
|
69
|
+
const r = new MetricsRecorder();
|
|
70
|
+
r.record({ tool: 'edit', durationMs: 12, ok: false });
|
|
71
|
+
const rec = r.snapshot().recent[0];
|
|
72
|
+
expect(rec.tool).toBe('edit');
|
|
73
|
+
expect(rec.durationMs).toBe(12);
|
|
74
|
+
expect(rec.ok).toBe(false);
|
|
75
|
+
expect(typeof rec.ts).toBe('number');
|
|
76
|
+
});
|
|
77
|
+
it('memory is null before any sample, populated after sampleMemory()', () => {
|
|
78
|
+
const r = new MetricsRecorder();
|
|
79
|
+
expect(r.snapshot().memory).toBeNull();
|
|
80
|
+
r.sampleMemory();
|
|
81
|
+
const mem = r.snapshot().memory;
|
|
82
|
+
expect(mem).not.toBeNull();
|
|
83
|
+
expect(typeof mem.rss).toBe('number');
|
|
84
|
+
expect(typeof mem.heapUsed).toBe('number');
|
|
85
|
+
expect(typeof mem.external).toBe('number');
|
|
86
|
+
expect(typeof mem.arrayBuffers).toBe('number');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe('MetricsRecorder.start/stop', () => {
|
|
90
|
+
afterEach(() => vi.useRealTimers());
|
|
91
|
+
it('start() seeds an immediate memory sample', () => {
|
|
92
|
+
const r = new MetricsRecorder();
|
|
93
|
+
r.start();
|
|
94
|
+
expect(r.snapshot().memory).not.toBeNull();
|
|
95
|
+
r.stop();
|
|
96
|
+
});
|
|
97
|
+
it('start() samples memory on the interval', () => {
|
|
98
|
+
vi.useFakeTimers();
|
|
99
|
+
const r = new MetricsRecorder({ memorySampleMs: 1000 });
|
|
100
|
+
const spy = vi.spyOn(r, 'sampleMemory');
|
|
101
|
+
r.start(); // one immediate seed
|
|
102
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
103
|
+
vi.advanceTimersByTime(3000);
|
|
104
|
+
expect(spy).toHaveBeenCalledTimes(4); // seed + 3 ticks
|
|
105
|
+
r.stop();
|
|
106
|
+
});
|
|
107
|
+
it('stop() is idempotent and clears the timer', () => {
|
|
108
|
+
vi.useFakeTimers();
|
|
109
|
+
const r = new MetricsRecorder({ memorySampleMs: 1000 });
|
|
110
|
+
const spy = vi.spyOn(r, 'sampleMemory');
|
|
111
|
+
r.start();
|
|
112
|
+
r.stop();
|
|
113
|
+
r.stop(); // no throw
|
|
114
|
+
vi.advanceTimersByTime(5000);
|
|
115
|
+
expect(spy).toHaveBeenCalledTimes(1); // only the seed; no ticks after stop
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
//# sourceMappingURL=recorder.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder.test.js","sourceRoot":"","sources":["../../../../test/metrics/recorder.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { afterEach, describe, expect, it, vi } from 'vitest';\nimport { MetricsRecorder } from '../../src/metrics/recorder.js';\n\ndescribe('MetricsRecorder.record + snapshot', () => {\n it('aggregates count/errorCount and min/max/mean from the sample', () => {\n const r = new MetricsRecorder();\n r.record({ tool: 'edit', durationMs: 10, ok: true });\n r.record({ tool: 'edit', durationMs: 30, ok: false });\n r.record({ tool: 'edit', durationMs: 20, ok: true });\n const edit = r.snapshot().tools.find((t) => t.tool === 'edit');\n expect(edit).toBeDefined();\n expect(edit!.count).toBe(3);\n expect(edit!.errorCount).toBe(1);\n expect(edit!.min).toBe(10);\n expect(edit!.max).toBe(30);\n expect(edit!.mean).toBe(20);\n });\n\n it('computes p50/p95 via nearest-rank over the sample', () => {\n const r = new MetricsRecorder();\n for (const d of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100])\n r.record({ tool: 'x', durationMs: d, ok: true });\n const x = r.snapshot().tools.find((t) => t.tool === 'x')!;\n // nearest-rank: p50 → idx ceil(0.5*10)-1 = 4 → 50; p95 → idx 9 → 100\n expect(x.p50).toBe(50);\n expect(x.p95).toBe(100);\n });\n\n it('separates aggregates by tool name', () => {\n const r = new MetricsRecorder();\n r.record({ tool: 'edit', durationMs: 5, ok: true });\n r.record({ tool: 'export', durationMs: 7, ok: true });\n expect(\n r\n .snapshot()\n .tools.map((t) => t.tool)\n .sort()\n ).toEqual(['edit', 'export']);\n });\n\n it('evicts oldest recent records beyond recentLimit', () => {\n const r = new MetricsRecorder({ recentLimit: 3 });\n for (let i = 0; i < 5; i++)\n r.record({ tool: 't', durationMs: i, ok: true });\n const recent = r.snapshot().recent;\n expect(recent).toHaveLength(3);\n // newest-last ring: durations 2,3,4 survive\n expect(recent.map((x) => x.durationMs)).toEqual([2, 3, 4]);\n });\n\n it('bounds the per-tool duration sample to sampleSize (most-recent wins)', () => {\n const r = new MetricsRecorder({ sampleSize: 2 });\n r.record({ tool: 't', durationMs: 1000, ok: true });\n r.record({ tool: 't', durationMs: 1, ok: true });\n r.record({ tool: 't', durationMs: 2, ok: true });\n const t = r.snapshot().tools.find((x) => x.tool === 't')!;\n // count still counts every call…\n expect(t.count).toBe(3);\n // …but min/max/percentiles come from the bounded sample [1, 2]\n expect(t.min).toBe(1);\n expect(t.max).toBe(2);\n expect(t.p95).toBe(2);\n expect(t.p50).toBe(1);\n });\n\n it('snapshot has the documented shape', () => {\n const r = new MetricsRecorder();\n r.record({ tool: 't', durationMs: 1, ok: true });\n const snap = r.snapshot();\n expect(snap).toHaveProperty('tools');\n expect(snap).toHaveProperty('recent');\n expect(snap).toHaveProperty('memory');\n expect(Array.isArray(snap.tools)).toBe(true);\n expect(Array.isArray(snap.recent)).toBe(true);\n });\n\n it('recent records carry tool, durationMs, ok, ts', () => {\n const r = new MetricsRecorder();\n r.record({ tool: 'edit', durationMs: 12, ok: false });\n const rec = r.snapshot().recent[0];\n expect(rec.tool).toBe('edit');\n expect(rec.durationMs).toBe(12);\n expect(rec.ok).toBe(false);\n expect(typeof rec.ts).toBe('number');\n });\n\n it('memory is null before any sample, populated after sampleMemory()', () => {\n const r = new MetricsRecorder();\n expect(r.snapshot().memory).toBeNull();\n r.sampleMemory();\n const mem = r.snapshot().memory;\n expect(mem).not.toBeNull();\n expect(typeof mem!.rss).toBe('number');\n expect(typeof mem!.heapUsed).toBe('number');\n expect(typeof mem!.external).toBe('number');\n expect(typeof mem!.arrayBuffers).toBe('number');\n });\n});\n\ndescribe('MetricsRecorder.start/stop', () => {\n afterEach(() => vi.useRealTimers());\n\n it('start() seeds an immediate memory sample', () => {\n const r = new MetricsRecorder();\n r.start();\n expect(r.snapshot().memory).not.toBeNull();\n r.stop();\n });\n\n it('start() samples memory on the interval', () => {\n vi.useFakeTimers();\n const r = new MetricsRecorder({ memorySampleMs: 1000 });\n const spy = vi.spyOn(r, 'sampleMemory');\n r.start(); // one immediate seed\n expect(spy).toHaveBeenCalledTimes(1);\n vi.advanceTimersByTime(3000);\n expect(spy).toHaveBeenCalledTimes(4); // seed + 3 ticks\n r.stop();\n });\n\n it('stop() is idempotent and clears the timer', () => {\n vi.useFakeTimers();\n const r = new MetricsRecorder({ memorySampleMs: 1000 });\n const spy = vi.spyOn(r, 'sampleMemory');\n r.start();\n r.stop();\n r.stop(); // no throw\n vi.advanceTimersByTime(5000);\n expect(spy).toHaveBeenCalledTimes(1); // only the seed; no ticks after stop\n });\n});\n"]}
|
|
@@ -128,6 +128,7 @@ describe('RevisionStore.listRoots', () => {
|
|
|
128
128
|
it('updates latestLeaf on each edit', async () => {
|
|
129
129
|
const { store } = makeStore();
|
|
130
130
|
const root = await store.write({ scene: 'v1', parent: null, title: 'T' });
|
|
131
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
131
132
|
const child = await store.write({ scene: 'v2', parent: root.id });
|
|
132
133
|
const list = await store.listRoots();
|
|
133
134
|
expect(list[0]).toMatchObject({
|
|
@@ -135,17 +136,27 @@ describe('RevisionStore.listRoots', () => {
|
|
|
135
136
|
latestLeaf: child.id
|
|
136
137
|
});
|
|
137
138
|
});
|
|
138
|
-
it('
|
|
139
|
+
it('latestLeaf is the most recently written leaf, even across a fork', async () => {
|
|
139
140
|
const { store } = makeStore();
|
|
140
141
|
const root = await store.write({ scene: 'v1', parent: null, title: 'T' });
|
|
141
|
-
|
|
142
|
+
await store.write({ scene: 'v2', parent: root.id });
|
|
143
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
142
144
|
const r3 = await store.write({ scene: 'v3', parent: root.id }); // fork off root
|
|
143
|
-
// Phase 1 simplification: latestLeaf is the *most recently written* leaf,
|
|
144
|
-
// regardless of structural position. After r3, latestLeaf=r3.
|
|
145
145
|
const list = await store.listRoots();
|
|
146
146
|
expect(list[0].latestLeaf).toBe(r3.id);
|
|
147
147
|
});
|
|
148
148
|
});
|
|
149
|
+
describe('RevisionStore.currentTitle', () => {
|
|
150
|
+
it('returns the creation title for a freshly created root', async () => {
|
|
151
|
+
const { store } = makeStore();
|
|
152
|
+
const root = await store.write({ scene: 'v1', parent: null, title: 'Hi' });
|
|
153
|
+
expect(await store.currentTitle(root.id)).toBe('Hi');
|
|
154
|
+
});
|
|
155
|
+
it('returns "Untitled" for an unknown root', async () => {
|
|
156
|
+
const { store } = makeStore();
|
|
157
|
+
expect(await store.currentTitle('deadbeefdead')).toBe('Untitled');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
149
160
|
describe('RevisionStore.historyOf', () => {
|
|
150
161
|
it('returns the chain from root to revision', async () => {
|
|
151
162
|
const { store } = makeStore();
|
|
@@ -163,24 +174,83 @@ describe('RevisionStore.historyOf', () => {
|
|
|
163
174
|
});
|
|
164
175
|
});
|
|
165
176
|
describe('RevisionStore.renameRoot', () => {
|
|
166
|
-
it('renames the root when given the root id', async () => {
|
|
177
|
+
it('renames the root when given the root id (reflected via currentTitle + listRoots)', async () => {
|
|
167
178
|
const { store } = makeStore();
|
|
168
179
|
const r = await store.write({ scene: 'a', parent: null, title: 'Old' });
|
|
169
|
-
await store.renameRoot(r.id, 'New');
|
|
170
|
-
|
|
171
|
-
expect(
|
|
180
|
+
const { rootRevision } = await store.renameRoot(r.id, 'New');
|
|
181
|
+
expect(rootRevision).toBe(r.id);
|
|
182
|
+
expect(await store.currentTitle(r.id)).toBe('New');
|
|
172
183
|
const list = await store.listRoots();
|
|
173
184
|
expect(list[0].title).toBe('New');
|
|
174
185
|
});
|
|
175
|
-
it('
|
|
186
|
+
it('does NOT mutate the root revision meta (titles are append-only events)', async () => {
|
|
187
|
+
const { store } = makeStore();
|
|
188
|
+
const r = await store.write({ scene: 'a', parent: null, title: 'Old' });
|
|
189
|
+
await store.renameRoot(r.id, 'New');
|
|
190
|
+
// The immutable revision keeps its creation-time title; the live title
|
|
191
|
+
// lives in the titles/ event log, not in the object meta.
|
|
192
|
+
expect((await store.read(r.id)).meta.title).toBe('Old');
|
|
193
|
+
});
|
|
194
|
+
it('walks to the root when given a mid-chain id', async () => {
|
|
176
195
|
const { store } = makeStore();
|
|
177
196
|
const r1 = await store.write({ scene: 'a', parent: null, title: 'Old' });
|
|
178
197
|
const r2 = await store.write({ scene: 'b', parent: r1.id });
|
|
179
198
|
const r3 = await store.write({ scene: 'c', parent: r2.id });
|
|
180
199
|
const { rootRevision } = await store.renameRoot(r3.id, 'Mid-chain rename');
|
|
181
200
|
expect(rootRevision).toBe(r1.id);
|
|
182
|
-
|
|
183
|
-
|
|
201
|
+
expect(await store.currentTitle(r1.id)).toBe('Mid-chain rename');
|
|
202
|
+
});
|
|
203
|
+
it('throws ENOENT for an unknown revision id', async () => {
|
|
204
|
+
const { store } = makeStore();
|
|
205
|
+
await expect(store.renameRoot('deadbeefdead', 'X')).rejects.toMatchObject({
|
|
206
|
+
code: 'ENOENT'
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
describe('RevisionStore root pointer', () => {
|
|
211
|
+
it('sets meta.root to its own id for a root revision', async () => {
|
|
212
|
+
const { store } = makeStore();
|
|
213
|
+
const root = await store.write({ scene: 'v1', parent: null, title: 'T' });
|
|
214
|
+
expect(root.meta.root).toBe(root.id);
|
|
215
|
+
});
|
|
216
|
+
it('inherits meta.root from the parent for a child revision', async () => {
|
|
217
|
+
const { store } = makeStore();
|
|
218
|
+
const root = await store.write({ scene: 'v1', parent: null, title: 'T' });
|
|
219
|
+
const child = await store.write({ scene: 'v2', parent: root.id });
|
|
220
|
+
const grandchild = await store.write({ scene: 'v3', parent: child.id });
|
|
221
|
+
expect(child.meta.root).toBe(root.id);
|
|
222
|
+
expect(grandchild.meta.root).toBe(root.id);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
describe('RevisionStore journal', () => {
|
|
226
|
+
it('appends one write-once journal entry per revision under journal/<root>/', async () => {
|
|
227
|
+
const { store, fs } = makeStore();
|
|
228
|
+
const root = await store.write({ scene: 'v1', parent: null, title: 'T' });
|
|
229
|
+
const child = await store.write({ scene: 'v2', parent: root.id });
|
|
230
|
+
const entries = await fs.readdir(`journal/${root.id}`);
|
|
231
|
+
expect(entries).toHaveLength(2);
|
|
232
|
+
// Each entry is `<paddedTs>-<leafId>`; the leaf id is after the first dash.
|
|
233
|
+
const leaves = entries.map((e) => e.slice(e.indexOf('-') + 1)).sort();
|
|
234
|
+
expect(leaves).toEqual([root.id, child.id].sort());
|
|
235
|
+
// Journal entries are empty — the filename carries all the data.
|
|
236
|
+
expect(await fs.readFile(`journal/${root.id}/${entries[0]}`, 'utf8')).toBe('');
|
|
237
|
+
});
|
|
238
|
+
it('seeds a title event under titles/<root>/ when a root is created', async () => {
|
|
239
|
+
const { store, fs } = makeStore();
|
|
240
|
+
const root = await store.write({
|
|
241
|
+
scene: 'v1',
|
|
242
|
+
parent: null,
|
|
243
|
+
title: 'Hello'
|
|
244
|
+
});
|
|
245
|
+
const titleEvents = await fs.readdir(`titles/${root.id}`);
|
|
246
|
+
expect(titleEvents).toHaveLength(1);
|
|
247
|
+
expect(await fs.readFile(`titles/${root.id}/${titleEvents[0]}`, 'utf8')).toBe('Hello');
|
|
248
|
+
});
|
|
249
|
+
it('defaults a titleless root to "Untitled" in its seed event', async () => {
|
|
250
|
+
const { store, fs } = makeStore();
|
|
251
|
+
const root = await store.write({ scene: 'v1', parent: null });
|
|
252
|
+
const titleEvents = await fs.readdir(`titles/${root.id}`);
|
|
253
|
+
expect(await fs.readFile(`titles/${root.id}/${titleEvents[0]}`, 'utf8')).toBe('Untitled');
|
|
184
254
|
});
|
|
185
255
|
});
|
|
186
256
|
describe('RevisionStore.treeOf', () => {
|