@harness-fe/mcp-server 4.0.0-next.2 → 4.0.0-next.3

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 (100) hide show
  1. package/dist/bin.d.ts +2 -0
  2. package/dist/bin.js +15 -0
  3. package/dist/daemon.d.ts +3 -3
  4. package/dist/daemon.js +1 -1
  5. package/dist/index.d.ts +4 -4
  6. package/dist/index.js +3 -3
  7. package/dist/mcp.d.ts +2 -2
  8. package/dist/mcp.js +42 -16
  9. package/dist/mcpHttp.d.ts +2 -2
  10. package/dist/mcpHttp.js +8 -2
  11. package/package.json +5 -7
  12. package/src/bin.ts +19 -0
  13. package/src/daemon.ts +3 -3
  14. package/src/experimental.test.ts +2 -2
  15. package/src/index.ts +4 -4
  16. package/src/mcp.ts +44 -20
  17. package/src/mcpHttp.test.ts +3 -3
  18. package/src/mcpHttp.ts +10 -4
  19. package/src/mcpLayer.e2e.test.ts +2 -2
  20. package/src/newCapabilities.e2e.test.ts +3 -3
  21. package/dist/auth.d.ts +0 -53
  22. package/dist/auth.js +0 -212
  23. package/dist/bridge.d.ts +0 -323
  24. package/dist/bridge.js +0 -1618
  25. package/dist/cli.d.ts +0 -18
  26. package/dist/cli.js +0 -293
  27. package/dist/dashboardApi.d.ts +0 -40
  28. package/dist/dashboardApi.js +0 -142
  29. package/dist/dashboardSpa.d.ts +0 -18
  30. package/dist/dashboardSpa.js +0 -180
  31. package/dist/dashboardUrl.d.ts +0 -13
  32. package/dist/dashboardUrl.js +0 -18
  33. package/dist/eventsHandler.d.ts +0 -24
  34. package/dist/eventsHandler.js +0 -114
  35. package/dist/identity.d.ts +0 -90
  36. package/dist/identity.js +0 -123
  37. package/dist/openBrowser.d.ts +0 -33
  38. package/dist/openBrowser.js +0 -63
  39. package/dist/remoteBridge.d.ts +0 -61
  40. package/dist/remoteBridge.js +0 -307
  41. package/dist/replayCreate.d.ts +0 -36
  42. package/dist/replayCreate.js +0 -156
  43. package/dist/replayViewer.d.ts +0 -20
  44. package/dist/replayViewer.js +0 -168
  45. package/dist/sessionRouter.d.ts +0 -45
  46. package/dist/sessionRouter.js +0 -88
  47. package/dist/store/JsonMemoryStore.d.ts +0 -52
  48. package/dist/store/JsonMemoryStore.js +0 -119
  49. package/dist/store/JsonTaskStore.d.ts +0 -21
  50. package/dist/store/JsonTaskStore.js +0 -53
  51. package/dist/store/JsonlStore.d.ts +0 -128
  52. package/dist/store/JsonlStore.js +0 -1172
  53. package/dist/store/MemoryEventStore.d.ts +0 -47
  54. package/dist/store/MemoryEventStore.js +0 -111
  55. package/dist/store/WriteQueue.d.ts +0 -51
  56. package/dist/store/WriteQueue.js +0 -142
  57. package/dist/store/index.d.ts +0 -6
  58. package/dist/store/index.js +0 -5
  59. package/dist/store/types.d.ts +0 -427
  60. package/dist/store/types.js +0 -19
  61. package/dist/visitorTimeline.d.ts +0 -24
  62. package/dist/visitorTimeline.js +0 -68
  63. package/src/auth.test.ts +0 -90
  64. package/src/auth.ts +0 -248
  65. package/src/bridge-auth.test.ts +0 -196
  66. package/src/bridge.test.ts +0 -1708
  67. package/src/bridge.ts +0 -1854
  68. package/src/cli.ts +0 -338
  69. package/src/dashboardApi.test.ts +0 -235
  70. package/src/dashboardApi.ts +0 -184
  71. package/src/dashboardSpa.test.ts +0 -239
  72. package/src/dashboardSpa.ts +0 -195
  73. package/src/dashboardUrl.test.ts +0 -46
  74. package/src/dashboardUrl.ts +0 -28
  75. package/src/eventsHandler.test.ts +0 -247
  76. package/src/eventsHandler.ts +0 -136
  77. package/src/identity.test.ts +0 -109
  78. package/src/identity.ts +0 -137
  79. package/src/openBrowser.test.ts +0 -103
  80. package/src/openBrowser.ts +0 -81
  81. package/src/remoteBridge.test.ts +0 -119
  82. package/src/remoteBridge.ts +0 -404
  83. package/src/replay.test.ts +0 -271
  84. package/src/replayCreate.ts +0 -194
  85. package/src/replayViewer.ts +0 -173
  86. package/src/sessionRouter.ts +0 -119
  87. package/src/store/JsonMemoryStore.test.ts +0 -175
  88. package/src/store/JsonMemoryStore.ts +0 -128
  89. package/src/store/JsonTaskStore.test.ts +0 -212
  90. package/src/store/JsonTaskStore.ts +0 -59
  91. package/src/store/JsonlStore.test.ts +0 -1538
  92. package/src/store/JsonlStore.ts +0 -1325
  93. package/src/store/MemoryEventStore.test.ts +0 -119
  94. package/src/store/MemoryEventStore.ts +0 -151
  95. package/src/store/WriteQueue.ts +0 -165
  96. package/src/store/identityTagging.test.ts +0 -67
  97. package/src/store/index.ts +0 -29
  98. package/src/store/types.ts +0 -532
  99. package/src/visitorTimeline.test.ts +0 -197
  100. package/src/visitorTimeline.ts +0 -89
@@ -1,194 +0,0 @@
1
- /**
2
- * Shared replay-export logic used by both the leader's MCP tool handler and
3
- * the leader's mcp.call dispatcher (for follower proxy calls).
4
- *
5
- * Takes a time window (or a center timestamp), pulls the overlapping rrweb
6
- * chunks for a single tab, concatenates the events, persists them as an
7
- * export, and returns the metadata + viewerUrl.
8
- */
9
-
10
- import type { IStore } from './store/index.js';
11
-
12
- /** rrweb event types: 2 = FullSnapshot baseline. Replay needs at least one. */
13
- function isFullSnapshotEvent(ev: unknown): boolean {
14
- return (
15
- typeof ev === 'object' &&
16
- ev !== null &&
17
- (ev as { type?: unknown }).type === 2
18
- );
19
- }
20
-
21
- export interface ReplayCreateArgs {
22
- sessionId: string;
23
- tabId?: string;
24
- ts?: number;
25
- windowMs?: number;
26
- since?: number;
27
- until?: number;
28
- label?: string;
29
- }
30
-
31
- export interface ReplayCreateResult {
32
- exportId?: string;
33
- viewerUrl?: string;
34
- sessionId: string;
35
- tabId?: string;
36
- since: number;
37
- until: number;
38
- startTs?: number;
39
- endTs?: number;
40
- durationMs?: number;
41
- eventCount?: number;
42
- chunkCount?: number;
43
- bytes?: number;
44
- createdAt?: number;
45
- label?: string;
46
- error?: string;
47
- }
48
-
49
- export function createReplayExport(
50
- store: IStore,
51
- baseUrl: string | undefined,
52
- input: ReplayCreateArgs,
53
- ): ReplayCreateResult {
54
- const { sessionId, tabId, ts, windowMs, since, until, label } = input;
55
-
56
- const session = store.getSession(sessionId);
57
- if (!session) {
58
- return { error: 'session not found', sessionId, since: since ?? 0, until: until ?? 0 };
59
- }
60
-
61
- let resolvedSince: number;
62
- let resolvedUntil: number;
63
- if (typeof since === 'number' && typeof until === 'number') {
64
- if (until <= since) {
65
- return { error: 'until must be greater than since', sessionId, since, until };
66
- }
67
- resolvedSince = since;
68
- resolvedUntil = until;
69
- } else if (typeof ts === 'number') {
70
- const radius = windowMs ?? 15_000;
71
- resolvedSince = ts - radius;
72
- resolvedUntil = ts + radius;
73
- } else {
74
- return {
75
- error: 'must provide either ts (with optional windowMs) or both since and until',
76
- sessionId,
77
- since: 0,
78
- until: 0,
79
- };
80
- }
81
-
82
- const chunks = store.sliceRecordings(sessionId, resolvedSince, resolvedUntil);
83
- if (chunks.length === 0) {
84
- return {
85
- error: 'no rrweb chunks found in window',
86
- sessionId,
87
- tabId,
88
- since: resolvedSince,
89
- until: resolvedUntil,
90
- };
91
- }
92
-
93
- let scopedTabId = tabId;
94
- if (!scopedTabId) {
95
- const byTab = new Map<string, number>();
96
- for (const c of chunks) byTab.set(c.tabId, (byTab.get(c.tabId) ?? 0) + c.eventCount);
97
- let best = '';
98
- let bestEvents = -1;
99
- for (const [t, count] of byTab) {
100
- if (count > bestEvents) { best = t; bestEvents = count; }
101
- }
102
- scopedTabId = best;
103
- }
104
-
105
- const tabChunks = chunks.filter((c) => c.tabId === scopedTabId);
106
- const events: unknown[] = [];
107
- let startTs = Infinity;
108
- let endTs = -Infinity;
109
- for (const c of tabChunks) {
110
- for (const ev of c.events) events.push(ev);
111
- if (c.startTs < startTs) startTs = c.startTs;
112
- if (c.endTs > endTs) endTs = c.endTs;
113
- }
114
-
115
- // rrweb replay requires a baseline pair — type:4 (Meta) + type:2
116
- // (FullSnapshot) — before any type:3 (IncrementalSnapshot) is meaningful.
117
- // If the window only contains incremental mutations (e.g. user picked a
118
- // narrow window long after the page loaded, or the very first chunk was
119
- // lost during a daemon restart), look back across earlier chunks for the
120
- // most recent baseline and prepend it. Replay will then start from that
121
- // earlier DOM state and roll mutations forward into the window.
122
- if (!events.some(isFullSnapshotEvent) && resolvedSince > 0) {
123
- const priorChunks = store
124
- .sliceRecordings(sessionId, 0, resolvedSince - 1)
125
- .filter((c) => c.tabId === scopedTabId)
126
- .sort((a, b) => a.startTs - b.startTs);
127
- // Walk backwards from the chunk closest to window start; the first
128
- // chunk that has a FullSnapshot becomes our baseline.
129
- for (let i = priorChunks.length - 1; i >= 0; i--) {
130
- const baseline = priorChunks[i];
131
- if (!baseline) continue;
132
- if (baseline.events.some(isFullSnapshotEvent)) {
133
- // Prepend baseline events (full chunk — preserves Meta + FS
134
- // ordering rrweb emitted them in). startTs widens to baseline.
135
- events.unshift(...baseline.events);
136
- if (baseline.startTs < startTs) startTs = baseline.startTs;
137
- break;
138
- }
139
- }
140
- }
141
-
142
- if (events.length < 2) {
143
- return {
144
- error: 'window contains fewer than 2 rrweb events — not enough to replay',
145
- sessionId,
146
- tabId: scopedTabId,
147
- since: resolvedSince,
148
- until: resolvedUntil,
149
- eventCount: events.length,
150
- };
151
- }
152
- if (!events.some(isFullSnapshotEvent)) {
153
- return {
154
- error:
155
- 'window contains no rrweb FullSnapshot (type:2) baseline, and no earlier baseline could be found — replay would be blank',
156
- sessionId,
157
- tabId: scopedTabId,
158
- since: resolvedSince,
159
- until: resolvedUntil,
160
- eventCount: events.length,
161
- };
162
- }
163
-
164
- const meta = store.writeExport({
165
- sessionId,
166
- tabId: scopedTabId,
167
- since: resolvedSince,
168
- until: resolvedUntil,
169
- label,
170
- events,
171
- startTs,
172
- endTs,
173
- chunkCount: tabChunks.length,
174
- });
175
-
176
- const viewerUrl = baseUrl ? `${baseUrl}/replay/${meta.exportId}` : undefined;
177
-
178
- return {
179
- exportId: meta.exportId,
180
- viewerUrl,
181
- sessionId,
182
- tabId: scopedTabId,
183
- since: resolvedSince,
184
- until: resolvedUntil,
185
- startTs,
186
- endTs,
187
- durationMs: endTs - startTs,
188
- eventCount: meta.eventCount,
189
- chunkCount: meta.chunkCount,
190
- bytes: meta.bytes,
191
- createdAt: meta.createdAt,
192
- label,
193
- };
194
- }
@@ -1,173 +0,0 @@
1
- /**
2
- * Replay viewer — HTTP routes for serving exported rrweb recordings.
3
- *
4
- * Routes (all served on the same port as the WS bridge):
5
- * GET /replay/:id HTML viewer page (rrweb-player UI)
6
- * GET /replay/:id.json Raw events array for the player to fetch
7
- * GET /replay/static/player.js Bundled rrweb-player (UMD)
8
- * GET /replay/static/player.css Bundled rrweb-player styles
9
- *
10
- * The HTML page loads the static assets relatively and then calls
11
- * /replay/:id.json to hydrate the player. This keeps the viewer self-contained
12
- * and offline-capable — no CDN dependencies.
13
- */
14
-
15
- import { readFileSync, existsSync } from 'node:fs';
16
- import { createRequire } from 'node:module';
17
- import { dirname, join } from 'node:path';
18
- import type { IncomingMessage, ServerResponse } from 'node:http';
19
- import type { IStore } from './store/index.js';
20
-
21
- const require = createRequire(import.meta.url);
22
-
23
- /** Locate the bundled rrweb-player dist directory. */
24
- function resolvePlayerDist(): string {
25
- // rrweb-player exposes package.json; use it to find the dist folder.
26
- const pkgPath = require.resolve('rrweb-player/package.json');
27
- return join(dirname(pkgPath), 'dist');
28
- }
29
-
30
- const PLAYER_DIST = (() => {
31
- try { return resolvePlayerDist(); } catch { return ''; }
32
- })();
33
-
34
- const VIEWER_HTML = (exportId: string, meta: { sessionId: string; tabId?: string; durationMs: number; eventCount: number }): string => `<!doctype html>
35
- <html lang="en">
36
- <head>
37
- <meta charset="utf-8" />
38
- <title>Harness replay · ${escapeHtml(exportId)}</title>
39
- <link rel="stylesheet" href="/replay/static/player.css" />
40
- <style>
41
- html, body { margin: 0; padding: 0; background: #1a1a1a; color: #eee; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
42
- .meta { padding: 12px 16px; font-size: 13px; color: #aaa; border-bottom: 1px solid #333; }
43
- .meta code { color: #6cf; }
44
- .stage { padding: 16px; display: flex; justify-content: center; }
45
- .error { padding: 24px; color: #f66; }
46
- </style>
47
- </head>
48
- <body>
49
- <div class="meta">
50
- Replay <code>${escapeHtml(exportId)}</code> · session <code>${escapeHtml(meta.sessionId)}</code>${meta.tabId ? ` · tab <code>${escapeHtml(meta.tabId)}</code>` : ''} · ${meta.eventCount} events · ${Math.round(meta.durationMs / 100) / 10}s
51
- </div>
52
- <div class="stage" id="stage"></div>
53
- <script src="/replay/static/player.js"></script>
54
- <script>
55
- (async function() {
56
- const stage = document.getElementById('stage');
57
- try {
58
- const resp = await fetch(${JSON.stringify(`/replay/${exportId}.json`)});
59
- if (!resp.ok) throw new Error('failed to fetch events: ' + resp.status);
60
- const events = await resp.json();
61
- if (!Array.isArray(events) || events.length < 2) {
62
- throw new Error('export has fewer than 2 events — cannot play back');
63
- }
64
- // rrwebPlayer is the UMD global.
65
- new rrwebPlayer({
66
- target: stage,
67
- props: {
68
- events,
69
- autoPlay: true,
70
- showController: true,
71
- width: Math.min(1280, window.innerWidth - 64),
72
- height: Math.min(720, window.innerHeight - 120),
73
- },
74
- });
75
- } catch (err) {
76
- stage.innerHTML = '<div class="error">Replay failed: ' + (err && err.message ? String(err.message).replace(/[<>&]/g, '') : 'unknown error') + '</div>';
77
- console.error('[harness replay]', err);
78
- }
79
- })();
80
- </script>
81
- </body>
82
- </html>`;
83
-
84
- function escapeHtml(s: string): string {
85
- return s.replace(/[&<>"']/g, (ch) => {
86
- switch (ch) {
87
- case '&': return '&amp;';
88
- case '<': return '&lt;';
89
- case '>': return '&gt;';
90
- case '"': return '&quot;';
91
- default: return '&#39;';
92
- }
93
- });
94
- }
95
-
96
- function send(res: ServerResponse, status: number, contentType: string, body: string | Buffer): void {
97
- res.statusCode = status;
98
- res.setHeader('content-type', contentType);
99
- res.setHeader('cache-control', 'no-store');
100
- res.end(body);
101
- }
102
-
103
- /**
104
- * Build a handler that dispatches /replay/* requests. Returns undefined for
105
- * non-replay paths so the caller can chain or 404.
106
- */
107
- export function createReplayHandler(store: IStore): (req: IncomingMessage, res: ServerResponse) => boolean {
108
- return (req, res) => {
109
- if (!req.url) return false;
110
- const url = new URL(req.url, 'http://localhost');
111
- const path = url.pathname;
112
- if (!path.startsWith('/replay/')) return false;
113
-
114
- // Static assets
115
- if (path === '/replay/static/player.js') {
116
- if (!PLAYER_DIST) return reply500(res, 'rrweb-player not installed');
117
- const file = join(PLAYER_DIST, 'index.js');
118
- if (!existsSync(file)) return reply500(res, 'rrweb-player bundle missing');
119
- send(res, 200, 'application/javascript; charset=utf-8', readFileSync(file));
120
- return true;
121
- }
122
- if (path === '/replay/static/player.css') {
123
- if (!PLAYER_DIST) return reply500(res, 'rrweb-player not installed');
124
- const file = join(PLAYER_DIST, 'style.css');
125
- if (!existsSync(file)) return reply500(res, 'rrweb-player styles missing');
126
- send(res, 200, 'text/css; charset=utf-8', readFileSync(file));
127
- return true;
128
- }
129
-
130
- // /replay/:id or /replay/:id.json
131
- const tail = path.slice('/replay/'.length);
132
- if (!tail || tail.includes('/')) {
133
- send(res, 404, 'text/plain; charset=utf-8', 'Not Found');
134
- return true;
135
- }
136
- const isJson = tail.endsWith('.json');
137
- const exportId = isJson ? tail.slice(0, -'.json'.length) : tail;
138
- if (!/^[A-Za-z0-9_-]+$/.test(exportId)) {
139
- send(res, 400, 'text/plain; charset=utf-8', 'Invalid export id');
140
- return true;
141
- }
142
-
143
- const meta = store.getExport(exportId);
144
- if (!meta) {
145
- send(res, 404, 'text/plain; charset=utf-8', `Unknown export: ${exportId}`);
146
- return true;
147
- }
148
-
149
- if (isJson) {
150
- const events = store.readExportEvents(exportId);
151
- if (!events) {
152
- send(res, 404, 'application/json; charset=utf-8', '{"error":"export events missing"}');
153
- return true;
154
- }
155
- send(res, 200, 'application/json; charset=utf-8', JSON.stringify(events));
156
- return true;
157
- }
158
-
159
- const html = VIEWER_HTML(exportId, {
160
- sessionId: meta.sessionId,
161
- tabId: meta.tabId,
162
- durationMs: Math.max(0, meta.endTs - meta.startTs),
163
- eventCount: meta.eventCount,
164
- });
165
- send(res, 200, 'text/html; charset=utf-8', html);
166
- return true;
167
- };
168
-
169
- function reply500(res: ServerResponse, msg: string): true {
170
- send(res, 500, 'text/plain; charset=utf-8', msg);
171
- return true;
172
- }
173
- }
@@ -1,119 +0,0 @@
1
- /**
2
- * SessionRouter — registry of connected peers (vite-plugin + runtime-client).
3
- *
4
- * - Project: 1 vite-plugin per project (uniqued by projectId)
5
- * - Tab : N runtime-clients per project (each browser tab is one)
6
- *
7
- * Active tab heuristic: most recently active (last command or last event).
8
- * Caller can override via explicit tabId on every command.
9
- */
10
-
11
- import type {
12
- PeerRole,
13
- TabInfo,
14
- } from '@harness-fe/protocol';
15
- import type { Principal } from './identity.js';
16
-
17
- export interface PeerSession {
18
- role: PeerRole;
19
- projectId: string;
20
- tabId?: string;
21
- /** Caller identity behind this connection (4.0 · P1). Defaults to `local`. */
22
- principal?: Principal;
23
- /** Runtime-client only: identifies the page load (sessionId) this connection belongs to. */
24
- sessionId?: string;
25
- /** Runtime-client only: stable per-browser visitor identifier. */
26
- visitorId?: string;
27
- /** App-supplied user identifier propagated from HarnessScript userId prop. */
28
- userId?: string;
29
- /** Opaque identifier for the underlying connection. */
30
- connectionId: string;
31
- lastActive: number;
32
- page?: { url?: string; title?: string; userAgent?: string };
33
- }
34
-
35
- export class SessionRouter {
36
- private peers = new Map<string, PeerSession>(); // key = connectionId
37
- private mostRecentTabId?: string;
38
-
39
- register(session: Omit<PeerSession, 'lastActive'>): PeerSession {
40
- const stored: PeerSession = { ...session, lastActive: Date.now() };
41
- this.peers.set(session.connectionId, stored);
42
- if (session.role === 'runtime-client' && session.tabId) {
43
- this.mostRecentTabId = session.tabId;
44
- }
45
- return stored;
46
- }
47
-
48
- unregister(connectionId: string): void {
49
- const peer = this.peers.get(connectionId);
50
- this.peers.delete(connectionId);
51
- if (peer?.tabId && this.mostRecentTabId === peer.tabId) {
52
- this.mostRecentTabId = this.findFallbackTab()?.tabId;
53
- }
54
- }
55
-
56
- touch(connectionId: string): void {
57
- const peer = this.peers.get(connectionId);
58
- if (!peer) return;
59
- peer.lastActive = Date.now();
60
- if (peer.role === 'runtime-client' && peer.tabId) {
61
- this.mostRecentTabId = peer.tabId;
62
- }
63
- }
64
-
65
- getByConnectionId(connectionId: string): PeerSession | undefined {
66
- return this.peers.get(connectionId);
67
- }
68
-
69
- findVitePlugin(projectId?: string): PeerSession | undefined {
70
- const candidates = [...this.peers.values()].filter(
71
- (p) => p.role === 'vite-plugin' || p.role === 'webpack-plugin',
72
- );
73
- if (!candidates.length) return undefined;
74
- if (projectId) return candidates.find((c) => c.projectId === projectId);
75
- // No projectId filter — return the most recent.
76
- return candidates.sort((a, b) => b.lastActive - a.lastActive)[0];
77
- }
78
-
79
- findTab(tabId?: string): PeerSession | undefined {
80
- if (tabId) {
81
- for (const p of this.peers.values()) {
82
- if (p.role === 'runtime-client' && p.tabId === tabId) return p;
83
- }
84
- return undefined;
85
- }
86
- if (this.mostRecentTabId) {
87
- return this.findTab(this.mostRecentTabId);
88
- }
89
- return this.findFallbackTab();
90
- }
91
-
92
- private findFallbackTab(): PeerSession | undefined {
93
- const tabs = [...this.peers.values()]
94
- .filter((p) => p.role === 'runtime-client' && p.tabId)
95
- .sort((a, b) => b.lastActive - a.lastActive);
96
- return tabs[0];
97
- }
98
-
99
- listTabs(): TabInfo[] {
100
- return [...this.peers.values()]
101
- .filter((p): p is PeerSession & { tabId: string } => p.role === 'runtime-client' && !!p.tabId)
102
- .map((p) => ({
103
- tabId: p.tabId,
104
- projectId: p.projectId,
105
- url: p.page?.url,
106
- title: p.page?.title,
107
- userAgent: p.page?.userAgent,
108
- connectedAt: p.lastActive,
109
- }));
110
- }
111
-
112
- listProjects(): string[] {
113
- const ids = new Set<string>();
114
- for (const p of this.peers.values()) {
115
- if (p.role === 'vite-plugin' || p.role === 'webpack-plugin') ids.add(p.projectId);
116
- }
117
- return [...ids];
118
- }
119
- }
@@ -1,175 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { mkdtempSync, rmSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import * as fc from 'fast-check';
6
- import { JsonMemoryStore } from './JsonMemoryStore.js';
7
-
8
- function makeStore() {
9
- const dir = mkdtempSync(join(tmpdir(), 'harness-memory-test-'));
10
- const store = new JsonMemoryStore(dir);
11
- return { store, dir };
12
- }
13
-
14
- function cleanup(dir: string) {
15
- try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
16
- }
17
-
18
- // ── Unit Tests ───────────────────────────────────────────────────────────────
19
-
20
- describe('JsonMemoryStore — unit tests', () => {
21
- let dir: string;
22
- let store: JsonMemoryStore;
23
-
24
- beforeEach(() => {
25
- ({ store, dir } = makeStore());
26
- });
27
-
28
- afterEach(() => {
29
- cleanup(dir);
30
- });
31
-
32
- it('get returns undefined for a missing key', () => {
33
- const result = store.get('proj', 'nonexistent-key');
34
- expect(result).toBeUndefined();
35
- });
36
-
37
- it('list returns empty array when no entries exist', () => {
38
- const result = store.list('proj');
39
- expect(result).toEqual([]);
40
- });
41
-
42
- it('set stores a value and get retrieves it', () => {
43
- store.set('proj', 'my-key', 'my-value');
44
- const entry = store.get('proj', 'my-key');
45
- expect(entry).toBeDefined();
46
- expect(entry!.key).toBe('my-key');
47
- expect(entry!.value).toBe('my-value');
48
- expect(typeof entry!.updatedAt).toBe('number');
49
- expect(entry!.updatedAt).toBeGreaterThan(0);
50
- });
51
-
52
- it('delete existing key returns { deleted: true }', () => {
53
- store.set('proj', 'to-delete', 'some-value');
54
- const deleted = store.delete('proj', 'to-delete');
55
- expect(deleted).toBe(true);
56
- expect(store.get('proj', 'to-delete')).toBeUndefined();
57
- });
58
-
59
- it('delete missing key returns { deleted: false }', () => {
60
- const deleted = store.delete('proj', 'does-not-exist');
61
- expect(deleted).toBe(false);
62
- });
63
-
64
- it('list returns all entries after multiple sets', () => {
65
- store.set('proj', 'key-a', 'val-a');
66
- store.set('proj', 'key-b', 'val-b');
67
- store.set('proj', 'key-c', 'val-c');
68
- const entries = store.list('proj');
69
- expect(entries).toHaveLength(3);
70
- const keys = entries.map((e) => e.key);
71
- expect(keys).toContain('key-a');
72
- expect(keys).toContain('key-b');
73
- expect(keys).toContain('key-c');
74
- });
75
-
76
- it('set overwrites an existing key', () => {
77
- store.set('proj', 'key', 'original');
78
- store.set('proj', 'key', 'updated');
79
- const entry = store.get('proj', 'key');
80
- expect(entry!.value).toBe('updated');
81
- const entries = store.list('proj');
82
- expect(entries).toHaveLength(1);
83
- });
84
-
85
- it('entries are isolated per projectId', () => {
86
- store.set('proj-a', 'key', 'value-a');
87
- store.set('proj-b', 'key', 'value-b');
88
- expect(store.get('proj-a', 'key')!.value).toBe('value-a');
89
- expect(store.get('proj-b', 'key')!.value).toBe('value-b');
90
- });
91
- });
92
-
93
- // ── Property-Based Tests ─────────────────────────────────────────────────────
94
-
95
- // Feature: persistence, Property 15: Memory set/get round-trip
96
- describe('Property 15: Memory set/get round-trip', () => {
97
- it('for any key-value pair, get after set returns the written value with a valid updatedAt timestamp', () => {
98
- // Validates: Requirements 7.1, 7.2, 9.1, 9.2
99
- fc.assert(
100
- fc.property(
101
- fc.tuple(fc.string({ minLength: 1 }), fc.string()),
102
- ([key, value]) => {
103
- const dir = mkdtempSync(join(tmpdir(), 'pbt-p15-'));
104
- try {
105
- const store = new JsonMemoryStore(dir);
106
- const before = Date.now();
107
- store.set('proj', key, value);
108
- const entry = store.get('proj', key);
109
-
110
- expect(entry).toBeDefined();
111
- expect(entry!.key).toBe(key);
112
- expect(entry!.value).toBe(value);
113
- expect(typeof entry!.updatedAt).toBe('number');
114
- expect(entry!.updatedAt).toBeGreaterThanOrEqual(before);
115
- } finally {
116
- cleanup(dir);
117
- }
118
- },
119
- ),
120
- { numRuns: 100 },
121
- );
122
- });
123
- });
124
-
125
- // Feature: persistence, Property 14: Memory list sort order
126
- describe('Property 14: Memory list sort order', () => {
127
- it('list returns entries sorted by updatedAt in strictly descending order', async () => {
128
- // Validates: Requirements 7.4, 9.3
129
- await fc.assert(
130
- fc.asyncProperty(
131
- fc.array(
132
- fc.tuple(fc.string({ minLength: 1 }), fc.string()),
133
- { minLength: 2 },
134
- ),
135
- async (pairs) => {
136
- const dir = mkdtempSync(join(tmpdir(), 'pbt-p14-'));
137
- try {
138
- const store = new JsonMemoryStore(dir);
139
-
140
- // Deduplicate keys to ensure distinct entries
141
- const seen = new Set<string>();
142
- const uniquePairs: Array<[string, string]> = [];
143
- for (const [k, v] of pairs) {
144
- if (!seen.has(k)) {
145
- seen.add(k);
146
- uniquePairs.push([k, v]);
147
- }
148
- }
149
-
150
- // Need at least 2 distinct keys to test sort order
151
- if (uniquePairs.length < 2) return;
152
-
153
- // Write entries with small delays to ensure distinct updatedAt values
154
- for (const [key, value] of uniquePairs) {
155
- store.set('proj', key, value);
156
- // Introduce a tiny delay to ensure distinct timestamps
157
- await new Promise((r) => setTimeout(r, 1));
158
- }
159
-
160
- const entries = store.list('proj');
161
- expect(entries.length).toBe(uniquePairs.length);
162
-
163
- // Verify strictly descending order
164
- for (let i = 1; i < entries.length; i++) {
165
- expect(entries[i - 1].updatedAt).toBeGreaterThanOrEqual(entries[i].updatedAt);
166
- }
167
- } finally {
168
- cleanup(dir);
169
- }
170
- },
171
- ),
172
- { numRuns: 50 },
173
- );
174
- });
175
- });