@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.
Files changed (128) hide show
  1. package/assets/docs/handbook/SKILL.md +6 -0
  2. package/dist/assets/inline.generated.js +1 -1
  3. package/dist/assets/inline.generated.js.map +1 -1
  4. package/dist/cli/http.d.ts.map +1 -1
  5. package/dist/cli/http.js +4 -0
  6. package/dist/cli/http.js.map +1 -1
  7. package/dist/cli/stdio.d.ts.map +1 -1
  8. package/dist/cli/stdio.js +55 -18
  9. package/dist/cli/stdio.js.map +1 -1
  10. package/dist/engine/cesdk-adapter.d.ts +7 -1
  11. package/dist/engine/cesdk-adapter.d.ts.map +1 -1
  12. package/dist/engine/cesdk-adapter.js +7 -1
  13. package/dist/engine/cesdk-adapter.js.map +1 -1
  14. package/dist/engine/unsplash-source.d.ts +8 -0
  15. package/dist/engine/unsplash-source.d.ts.map +1 -0
  16. package/dist/engine/unsplash-source.js +77 -0
  17. package/dist/engine/unsplash-source.js.map +1 -0
  18. package/dist/http/server.d.ts.map +1 -1
  19. package/dist/http/server.js +0 -1
  20. package/dist/http/server.js.map +1 -1
  21. package/dist/server.d.ts +0 -2
  22. package/dist/server.d.ts.map +1 -1
  23. package/dist/server.js.map +1 -1
  24. package/dist/test/test/cli/redirect-console.test.d.ts +2 -0
  25. package/dist/test/test/cli/redirect-console.test.d.ts.map +1 -0
  26. package/dist/test/test/cli/redirect-console.test.js +47 -0
  27. package/dist/test/test/cli/redirect-console.test.js.map +1 -0
  28. package/dist/test/test/cli/resolve-license.test.d.ts +2 -0
  29. package/dist/test/test/cli/resolve-license.test.d.ts.map +1 -0
  30. package/dist/test/test/cli/resolve-license.test.js +20 -0
  31. package/dist/test/test/cli/resolve-license.test.js.map +1 -0
  32. package/dist/test/test/cli/stdio-license.test.d.ts +2 -0
  33. package/dist/test/test/cli/stdio-license.test.d.ts.map +1 -0
  34. package/dist/test/test/cli/stdio-license.test.js +19 -0
  35. package/dist/test/test/cli/stdio-license.test.js.map +1 -0
  36. package/dist/test/test/config.test.d.ts +2 -0
  37. package/dist/test/test/config.test.d.ts.map +1 -0
  38. package/dist/test/test/config.test.js +91 -0
  39. package/dist/test/test/config.test.js.map +1 -0
  40. package/dist/test/test/engine/asset-sources.test.d.ts +2 -0
  41. package/dist/test/test/engine/asset-sources.test.d.ts.map +1 -0
  42. package/dist/test/test/engine/asset-sources.test.js +100 -0
  43. package/dist/test/test/engine/asset-sources.test.js.map +1 -0
  44. package/dist/test/test/engine/mutex.test.d.ts +2 -0
  45. package/dist/test/test/engine/mutex.test.d.ts.map +1 -0
  46. package/dist/test/test/engine/mutex.test.js +63 -0
  47. package/dist/test/test/engine/mutex.test.js.map +1 -0
  48. package/dist/test/test/engine/session.test.js +15 -0
  49. package/dist/test/test/engine/session.test.js.map +1 -1
  50. package/dist/test/test/engine/unsplash-source.test.d.ts +2 -0
  51. package/dist/test/test/engine/unsplash-source.test.d.ts.map +1 -0
  52. package/dist/test/test/engine/unsplash-source.test.js +129 -0
  53. package/dist/test/test/engine/unsplash-source.test.js.map +1 -0
  54. package/dist/test/test/handbook-contract.test.js +8 -4
  55. package/dist/test/test/handbook-contract.test.js.map +1 -1
  56. package/dist/test/test/http/server.test.js +129 -0
  57. package/dist/test/test/http/server.test.js.map +1 -1
  58. package/dist/test/test/http/session-id-header.test.d.ts +2 -0
  59. package/dist/test/test/http/session-id-header.test.d.ts.map +1 -0
  60. package/dist/test/test/http/session-id-header.test.js +76 -0
  61. package/dist/test/test/http/session-id-header.test.js.map +1 -0
  62. package/dist/test/test/http/session-manager.test.js +14 -0
  63. package/dist/test/test/http/session-manager.test.js.map +1 -1
  64. package/dist/test/test/http/workspace-auth.test.d.ts +2 -0
  65. package/dist/test/test/http/workspace-auth.test.d.ts.map +1 -0
  66. package/dist/test/test/http/workspace-auth.test.js +41 -0
  67. package/dist/test/test/http/workspace-auth.test.js.map +1 -0
  68. package/dist/test/test/metrics/mount.test.d.ts +2 -0
  69. package/dist/test/test/metrics/mount.test.d.ts.map +1 -0
  70. package/dist/test/test/metrics/mount.test.js +59 -0
  71. package/dist/test/test/metrics/mount.test.js.map +1 -0
  72. package/dist/test/test/metrics/recorder.test.d.ts +2 -0
  73. package/dist/test/test/metrics/recorder.test.d.ts.map +1 -0
  74. package/dist/test/test/metrics/recorder.test.js +118 -0
  75. package/dist/test/test/metrics/recorder.test.js.map +1 -0
  76. package/dist/test/test/revisions/store.test.js +81 -11
  77. package/dist/test/test/revisions/store.test.js.map +1 -1
  78. package/dist/test/test/server.metrics.test.d.ts +2 -0
  79. package/dist/test/test/server.metrics.test.d.ts.map +1 -0
  80. package/dist/test/test/server.metrics.test.js +87 -0
  81. package/dist/test/test/server.metrics.test.js.map +1 -0
  82. package/dist/test/test/server.test.js +33 -4
  83. package/dist/test/test/server.test.js.map +1 -1
  84. package/dist/test/test/tools/asset-search.test.d.ts +2 -0
  85. package/dist/test/test/tools/asset-search.test.d.ts.map +1 -0
  86. package/dist/test/test/tools/asset-search.test.js +121 -0
  87. package/dist/test/test/tools/asset-search.test.js.map +1 -0
  88. package/dist/test/test/tools/edit.test.js +71 -9
  89. package/dist/test/test/tools/edit.test.js.map +1 -1
  90. package/dist/test/test/tools/export.test.js +60 -30
  91. package/dist/test/test/tools/export.test.js.map +1 -1
  92. package/dist/test/test/tools/index.test.d.ts +2 -0
  93. package/dist/test/test/tools/index.test.d.ts.map +1 -0
  94. package/dist/test/test/tools/index.test.js +55 -0
  95. package/dist/test/test/tools/index.test.js.map +1 -0
  96. package/dist/test/test/tools/metrics.test.d.ts +2 -0
  97. package/dist/test/test/tools/metrics.test.d.ts.map +1 -0
  98. package/dist/test/test/tools/metrics.test.js +29 -0
  99. package/dist/test/test/tools/metrics.test.js.map +1 -0
  100. package/dist/test/test/tools/preview.test.js +6 -4
  101. package/dist/test/test/tools/preview.test.js.map +1 -1
  102. package/dist/test/test/tools/rename.test.js +1 -2
  103. package/dist/test/test/tools/rename.test.js.map +1 -1
  104. package/dist/test/test/tools/save.test.d.ts +2 -0
  105. package/dist/test/test/tools/save.test.d.ts.map +1 -0
  106. package/dist/test/test/tools/save.test.js +137 -0
  107. package/dist/test/test/tools/save.test.js.map +1 -0
  108. package/dist/test/test/viewer/router.test.js +56 -0
  109. package/dist/test/test/viewer/router.test.js.map +1 -1
  110. package/dist/test/test/viewer/session-mount.test.d.ts +2 -0
  111. package/dist/test/test/viewer/session-mount.test.d.ts.map +1 -0
  112. package/dist/test/test/viewer/session-mount.test.js +52 -0
  113. package/dist/test/test/viewer/session-mount.test.js.map +1 -0
  114. package/dist/test/test/viewer/url-builder.test.js +76 -9
  115. package/dist/test/test/viewer/url-builder.test.js.map +1 -1
  116. package/dist/test/test/workspace/s3.test.d.ts +2 -0
  117. package/dist/test/test/workspace/s3.test.d.ts.map +1 -0
  118. package/dist/test/test/workspace/s3.test.js +153 -0
  119. package/dist/test/test/workspace/s3.test.js.map +1 -0
  120. package/dist/test/test/workspace/scoped.test.d.ts +2 -0
  121. package/dist/test/test/workspace/scoped.test.d.ts.map +1 -0
  122. package/dist/test/test/workspace/scoped.test.js +59 -0
  123. package/dist/test/test/workspace/scoped.test.js.map +1 -0
  124. package/dist/test/tsconfig.test.tsbuildinfo +1 -1
  125. package/dist/tools/index.d.ts.map +1 -1
  126. package/dist/tools/index.js +14 -4
  127. package/dist/tools/index.js.map +1 -1
  128. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=workspace-auth.test.d.ts.map
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=mount.test.d.ts.map
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=recorder.test.d.ts.map
@@ -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":"AAAA,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAEhE,QAAQ,CAAC,mCAAmC,EAAE,GAAG,EAAE;IACjD,EAAE,CAAC,8DAA8D,EAAE,GAAG,EAAE;QACtE,MAAM,CAAC,GAAG,IAAI,eAAe,EAAE,CAAC;QAChC,CAAC,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;QACrD,CAAC,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;QACtD,CAAC,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;QACrD,MAAM,IAAI,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC;QAC/D,MAAM,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC;QAC3B,MAAM,CAAC,IAAK,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC5B,MAAM,CAAC,IAAK,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACjC,MAAM,CAAC,IAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC3B,MAAM,CAAC,IAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC3B,MAAM,CAAC,IAAK,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC9B,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mDAAmD,EAAE,GAAG,EAAE;QAC3D,MAAM,CAAC,GAAG,IAAI,eAAe,EAAE,CAAC;QAChC,KAAK,MAAM,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC;YACvD,CAAC,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;QACnD,MAAM,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,GAAG,CAAE,CAAC;QAC1D,qEAAqE;QACrE,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACvB,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC1B,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mCAAmC,EAAE,GAAG,EAAE;QAC3C,MAAM,CAAC,GAAG,IAAI,eAAe,EAAE,CAAC;QAChC,CAAC,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;QACpD,CAAC,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;QACtD,MAAM,CACJ,CAAC;aACE,QAAQ,EAAE;aACV,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;aACxB,IAAI,EAAE,CACV,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;IAChC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iDAAiD,EAAE,GAAG,EAAE;QACzD,MAAM,CAAC,GAAG,IAAI,eAAe,CAAC,EAAE,WAAW,EAAE,CAAC,EAAE,CAAC,CAAC;QAClD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE;YACxB,CAAC,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;QACnD,MAAM,MAAM,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC,MAAM,CAAC;QACnC,MAAM,CAAC,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QAC/B,4CAA4C;QAC5C,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAC7D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sEAAsE,EAAE,GAAG,EAAE;QAC9E,MAAM,CAAC,GAAG,IAAI,eAAe,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC;QACjD,CAAC,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;QACpD,CAAC,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;QACjD,CAAC,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;QACjD,MAAM,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,GAAG,CAAE,CAAC;QAC1D,iCAAiC;QACjC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACxB,+DAA+D;QAC/D,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACtB,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACtB,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACtB,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACxB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mCAAmC,EAAE,GAAG,EAAE;QAC3C,MAAM,CAAC,GAAG,IAAI,eAAe,EAAE,CAAC;QAChC,CAAC,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;QACjD,MAAM,IAAI,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC;QAC1B,MAAM,CAAC,IAAI,CAAC,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;QACrC,MAAM,CAAC,IAAI,CAAC,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC;QACtC,MAAM,CAAC,IAAI,CAAC,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC;QACtC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7C,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+CAA+C,EAAE,GAAG,EAAE;QACvD,MAAM,CAAC,GAAG,IAAI,eAAe,EAAE,CAAC;QAChC,CAAC,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;QACtD,MAAM,GAAG,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QACnC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAC9B,MAAM,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAChC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC3B,MAAM,CAAC,OAAO,GAAG,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kEAAkE,EAAE,GAAG,EAAE;QAC1E,MAAM,CAAC,GAAG,IAAI,eAAe,EAAE,CAAC;QAChC,MAAM,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,CAAC;QACvC,CAAC,CAAC,YAAY,EAAE,CAAC;QACjB,MAAM,GAAG,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC,MAAM,CAAC;QAChC,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC;QAC3B,MAAM,CAAC,OAAO,GAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACvC,MAAM,CAAC,OAAO,GAAI,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC5C,MAAM,CAAC,OAAO,GAAI,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC5C,MAAM,CAAC,OAAO,GAAI,CAAC,YAAY,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAClD,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,4BAA4B,EAAE,GAAG,EAAE;IAC1C,SAAS,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,aAAa,EAAE,CAAC,CAAC;IAEpC,EAAE,CAAC,0CAA0C,EAAE,GAAG,EAAE;QAClD,MAAM,CAAC,GAAG,IAAI,eAAe,EAAE,CAAC;QAChC,CAAC,CAAC,KAAK,EAAE,CAAC;QACV,MAAM,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC;QAC3C,CAAC,CAAC,IAAI,EAAE,CAAC;IACX,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,wCAAwC,EAAE,GAAG,EAAE;QAChD,EAAE,CAAC,aAAa,EAAE,CAAC;QACnB,MAAM,CAAC,GAAG,IAAI,eAAe,CAAC,EAAE,cAAc,EAAE,IAAI,EAAE,CAAC,CAAC;QACxD,MAAM,GAAG,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,cAAc,CAAC,CAAC;QACxC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,qBAAqB;QAChC,MAAM,CAAC,GAAG,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC;QACrC,EAAE,CAAC,mBAAmB,CAAC,IAAI,CAAC,CAAC;QAC7B,MAAM,CAAC,GAAG,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC,CAAC,iBAAiB;QACvD,CAAC,CAAC,IAAI,EAAE,CAAC;IACX,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2CAA2C,EAAE,GAAG,EAAE;QACnD,EAAE,CAAC,aAAa,EAAE,CAAC;QACnB,MAAM,CAAC,GAAG,IAAI,eAAe,CAAC,EAAE,cAAc,EAAE,IAAI,EAAE,CAAC,CAAC;QACxD,MAAM,GAAG,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,cAAc,CAAC,CAAC;QACxC,CAAC,CAAC,KAAK,EAAE,CAAC;QACV,CAAC,CAAC,IAAI,EAAE,CAAC;QACT,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,WAAW;QACrB,EAAE,CAAC,mBAAmB,CAAC,IAAI,CAAC,CAAC;QAC7B,MAAM,CAAC,GAAG,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC,CAAC,qCAAqC;IAC7E,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC","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('does not update latestLeaf when editing off an OLDER revision (fork case)', async () => {
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
- const _r2 = await store.write({ scene: 'v2', parent: root.id });
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
- const root = await store.read(r.id);
171
- expect(root.meta.title).toBe('New');
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('walks parents to find the root when given a mid-chain id', async () => {
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
- const root = await store.read(r1.id);
183
- expect(root.meta.title).toBe('Mid-chain rename');
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', () => {