repoburg 1.3.255 → 1.3.257

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 (75) hide show
  1. package/daemon/dist/index.js +61 -3
  2. package/daemon/dist/staticServing.js +2 -2
  3. package/daemon/dist/tabtape/api/follow.js +174 -0
  4. package/daemon/dist/tabtape/api/http.js +221 -0
  5. package/daemon/dist/tabtape/api/record.js +149 -0
  6. package/daemon/dist/tabtape/api/router.js +519 -0
  7. package/daemon/dist/tabtape/api/router.spec.js +604 -0
  8. package/daemon/dist/tabtape/api/static.js +70 -0
  9. package/daemon/dist/tabtape/api/ws.js +166 -0
  10. package/daemon/dist/tabtape/buffer/buffer.js +347 -0
  11. package/daemon/dist/tabtape/buffer/buffer.spec.js +137 -0
  12. package/daemon/dist/tabtape/config.js +108 -0
  13. package/daemon/dist/tabtape/e2e-server.js +96 -0
  14. package/daemon/dist/tabtape/errors.js +77 -0
  15. package/daemon/dist/tabtape/export/export.js +331 -0
  16. package/daemon/dist/tabtape/export/export.spec.js +262 -0
  17. package/daemon/dist/tabtape/har/gostd.js +395 -0
  18. package/daemon/dist/tabtape/har/gostd.spec.js +139 -0
  19. package/daemon/dist/tabtape/har/har.js +582 -0
  20. package/daemon/dist/tabtape/har/har.spec.js +325 -0
  21. package/daemon/dist/tabtape/index.js +134 -0
  22. package/daemon/dist/tabtape/index.spec.js +104 -0
  23. package/daemon/dist/tabtape/lock.js +67 -0
  24. package/daemon/dist/tabtape/log.js +32 -0
  25. package/daemon/dist/tabtape/mcp/definitions.js +362 -0
  26. package/daemon/dist/tabtape/mcp/gofmt.js +227 -0
  27. package/daemon/dist/tabtape/mcp/params.js +345 -0
  28. package/daemon/dist/tabtape/mcp/params.spec.js +172 -0
  29. package/daemon/dist/tabtape/mcp/recordLink.js +122 -0
  30. package/daemon/dist/tabtape/mcp/server.js +411 -0
  31. package/daemon/dist/tabtape/mcp/server.spec.js +722 -0
  32. package/daemon/dist/tabtape/mcp/tools.js +1085 -0
  33. package/daemon/dist/tabtape/query/format.js +195 -0
  34. package/daemon/dist/tabtape/query/network.js +133 -0
  35. package/daemon/dist/tabtape/query/perf.js +93 -0
  36. package/daemon/dist/tabtape/query/query.spec.js +233 -0
  37. package/daemon/dist/tabtape/query/reader.js +602 -0
  38. package/daemon/dist/tabtape/query/types.js +274 -0
  39. package/daemon/dist/tabtape/redact.js +37 -0
  40. package/daemon/dist/tabtape/repro/lang.js +95 -0
  41. package/daemon/dist/tabtape/repro/repro.js +422 -0
  42. package/daemon/dist/tabtape/repro/repro.spec.js +210 -0
  43. package/daemon/dist/tabtape/sink.js +24 -0
  44. package/daemon/dist/tabtape/store/record.js +99 -0
  45. package/daemon/dist/tabtape/store/segment.js +267 -0
  46. package/daemon/dist/tabtape/store/store.js +846 -0
  47. package/daemon/dist/tabtape/store/store.spec.js +485 -0
  48. package/daemon/dist/tabtape/testing/querytest.js +119 -0
  49. package/daemon/dist/tabtape/testing/tmp.js +33 -0
  50. package/package.json +3 -2
  51. package/tabtape/extension/dist/background.js +8 -0
  52. package/tabtape/extension/dist/background.js.map +7 -0
  53. package/tabtape/extension/dist/bridge.js +3 -0
  54. package/tabtape/extension/dist/bridge.js.map +7 -0
  55. package/tabtape/extension/dist/icons/128.png +0 -0
  56. package/tabtape/extension/dist/icons/16.png +0 -0
  57. package/tabtape/extension/dist/icons/32.png +0 -0
  58. package/tabtape/extension/dist/icons/48.png +0 -0
  59. package/tabtape/extension/dist/manifest.json +28 -0
  60. package/tabtape/extension/dist/popup.css +62 -0
  61. package/tabtape/extension/dist/popup.html +34 -0
  62. package/tabtape/extension/dist/popup.js +4 -0
  63. package/tabtape/extension/dist/popup.js.map +7 -0
  64. package/tabtape/extension/dist/recorder.js +133 -0
  65. package/tabtape/extension/dist/recorder.js.map +1 -0
  66. package/tabtape/extension/dist/state.js +2 -0
  67. package/tabtape/extension/dist/state.js.map +1 -0
  68. package/tabtape/playwright/mcp.mjs +58 -0
  69. package/tabtape/playwright/tabtape.d.mts +37 -0
  70. package/tabtape/playwright/tabtape.mjs +146 -0
  71. package/tabtape/ui/dist/app/assets/index-Canv3Ljk.css +1 -0
  72. package/tabtape/ui/dist/app/assets/index-iWNSSrAE.js +145 -0
  73. package/tabtape/ui/dist/app/index.html +14 -0
  74. package/tabtape/ui/dist/export/viewer.css +2 -0
  75. package/tabtape/ui/dist/export/viewer.js +143 -0
@@ -0,0 +1,325 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ const fs_1 = __importDefault(require("fs"));
40
+ const path_1 = __importDefault(require("path"));
41
+ const reader_1 = require("../query/reader");
42
+ const redact_1 = require("../redact");
43
+ const qt = __importStar(require("../testing/querytest"));
44
+ const tmp_1 = require("../testing/tmp");
45
+ const har_1 = require("./har");
46
+ /** TABTAPE_UPDATE_GOLDEN=1 rewrites the golden files; review the diff. */
47
+ const UPDATE = process.env.TABTAPE_UPDATE_GOLDEN === '1';
48
+ const T = qt.T0;
49
+ afterEach(async () => {
50
+ await qt.closeTestStores();
51
+ (0, tmp_1.cleanupTempDirs)();
52
+ });
53
+ const blocked = (name, ...reasons) => ({ name, reasons });
54
+ async function session() {
55
+ const st = qt.testStore();
56
+ const id = 's1';
57
+ qt.start(st, id, 1000, 'http://localhost:7788/', 'Demo');
58
+ qt.append(st, id, qt.rrMeta(1000, 'http://localhost:7788/', 1280, 800), qt.rrFull(1000), qt.pageView(1000, 'load', 'http://localhost:7788/', 'Demo'), qt.net(900, { id: 'd1', kind: 'navigation', method: 'GET', url: 'http://localhost:7788/', status: 200, duration: 40, navType: 'navigate' }), qt.net(1050, { id: 'r1', kind: 'resource', method: 'GET', url: 'http://localhost:7788/logo.svg', initiator: 'img', duration: 5.4, transferSize: 300 }), qt.net(1100, {
59
+ id: 'f1',
60
+ kind: 'fetch',
61
+ method: 'GET',
62
+ url: '/api/data?x=1&y=two%20words',
63
+ status: 200,
64
+ statusText: 'OK',
65
+ duration: 50,
66
+ ttfb: 20,
67
+ reqHeaders: { authorization: 'Bearer test-token', 'x-test': '1' },
68
+ resHeaders: { 'content-type': 'application/json', 'set-cookie': 'a=1; Path=/' },
69
+ resBody: '{"ok":true}',
70
+ resSize: 11,
71
+ }), qt.net(1200, {
72
+ id: 'x1',
73
+ kind: 'xhr',
74
+ method: 'post',
75
+ url: 'http://localhost:7788/api/broken',
76
+ status: 500,
77
+ statusText: 'Internal Server Error',
78
+ duration: 12,
79
+ reqHeaders: { 'content-type': 'application/json' },
80
+ reqBody: '{"a":1}',
81
+ resBody: 'x',
82
+ resBodyTruncated: true,
83
+ }), qt.net(1300, { id: 'f2', kind: 'fetch', method: 'GET', url: 'http://localhost:9/down', error: 'TypeError: Failed to fetch', duration: 3 }), qt.net(1900, { id: 'n1', kind: 'navigation', method: 'GET', url: 'http://localhost:7788/b.html', status: 200, duration: 80, ttfb: 30, navType: 'navigate' }), qt.pageView(2000, 'load', 'http://localhost:7788/b.html', 'B'), qt.net(2100, { id: 'b1', kind: 'beacon', method: 'POST', url: '/collect', reqBodyNote: 'Blob, 12 bytes', pending: true }), qt.rrInc(2500));
84
+ return new reader_1.Reader(st).view(id);
85
+ }
86
+ async function deepSession() {
87
+ const st = qt.testStore();
88
+ const id = 'd1';
89
+ qt.start(st, id, 1000, 'http://localhost:7788/ws.html', 'WS');
90
+ qt.append(st, id, qt.rrMeta(1000, 'http://localhost:7788/ws.html', 1280, 800), qt.rrFull(1000), qt.pageView(1000, 'load', 'http://localhost:7788/ws.html', 'WS'), qt.net(1100, {
91
+ id: 'cdp-1',
92
+ kind: 'fetch',
93
+ method: 'GET',
94
+ url: 'http://localhost:7788/api/products',
95
+ status: 200,
96
+ statusText: 'OK',
97
+ source: 'cdp',
98
+ resourceType: 'fetch',
99
+ protocol: 'http/1.1',
100
+ remoteAddress: '127.0.0.1:7788',
101
+ duration: 42,
102
+ ttfb: 30,
103
+ timing: { blocked: 1.5, dns: 2, connect: 3, ssl: -1, send: 0.5, wait: 30, receive: 5 },
104
+ reqHeaders: { cookie: 'sid=abc; theme=dark', accept: '*/*' },
105
+ resHeaders: { 'content-type': 'application/json', 'set-cookie': 'tabtape_demo=1; SameSite=None\nok=1; Path=/' },
106
+ resBody: '[{"name":"Trail shoes"}]',
107
+ resSize: 24,
108
+ transferSize: 180,
109
+ blockedSetCookies: [blocked('tabtape_demo', 'SameSiteNoneInsecure')],
110
+ }), qt.net(1200, {
111
+ id: 'cdp-2',
112
+ kind: 'fetch',
113
+ method: 'GET',
114
+ url: 'http://127.0.0.1:7789/api/cors',
115
+ source: 'cdp',
116
+ resourceType: 'fetch',
117
+ error: 'net::ERR_FAILED',
118
+ corsErrorStatus: { corsError: 'MissingAllowOriginHeader' },
119
+ duration: 4,
120
+ }), qt.net(1250, {
121
+ id: 'cdp-3',
122
+ kind: 'resource',
123
+ method: 'GET',
124
+ url: 'http://localhost:7788/logo.svg',
125
+ status: 200,
126
+ source: 'cdp',
127
+ resourceType: 'image',
128
+ initiator: 'image',
129
+ cache: 'memory',
130
+ duration: 0.4,
131
+ }), qt.net(1300, {
132
+ id: 'cdp-4',
133
+ kind: 'fetch',
134
+ method: 'GET',
135
+ url: 'http://localhost:7788/api/worker-data',
136
+ status: 200,
137
+ source: 'cdp',
138
+ resourceType: 'fetch',
139
+ worker: { type: 'worker', url: 'http://localhost:7788/worker.js' },
140
+ duration: 8,
141
+ }), qt.ws(1450, 'ws-1', 'send', '{"n":1}'), qt.ws(1460, 'ws-1', 'recv', '{"n":1}'), { ts: 1470, type: 'ws', data: { conn: 'ws-1', dir: 'recv', opcode: 2, size: 1024 } },
142
+ // Written when the socket closes, with the open time as ts.
143
+ qt.net(1400, {
144
+ id: 'ws-1',
145
+ kind: 'websocket',
146
+ method: 'GET',
147
+ url: 'ws://localhost:7788/echo',
148
+ status: 101,
149
+ statusText: 'Switching Protocols',
150
+ source: 'cdp',
151
+ resourceType: 'websocket',
152
+ duration: 600,
153
+ closedAt: 2000,
154
+ reqHeaders: { upgrade: 'websocket', cookie: 'sid=abc' },
155
+ resHeaders: { upgrade: 'websocket' },
156
+ frames: { sent: 1, received: 2, sentBytes: 7, receivedBytes: 1031 },
157
+ }), qt.rrInc(2500));
158
+ return new reader_1.Reader(st).view(id);
159
+ }
160
+ /** A session with a Meta + full snapshot at T and the given records. */
161
+ async function sessionWith(...recs) {
162
+ const st = qt.testStore();
163
+ qt.start(st, 's1', T, 'http://app.test/', 'App');
164
+ qt.append(st, 's1', qt.rrMeta(T, 'http://app.test/', 800, 600), qt.rrFull(T), qt.pageView(T, 'load', 'http://app.test/', 'App'), ...recs);
165
+ return new reader_1.Reader(st).view('s1');
166
+ }
167
+ /** The HAR as the API writes it. */
168
+ async function build(v, opts = {}) {
169
+ const har = await (0, har_1.buildHar)(v, { redact: new redact_1.RedactHeaders(), ...opts, version: 'test' });
170
+ return JSON.stringify(har, null, 2) + '\n';
171
+ }
172
+ async function parse(v, opts = {}) {
173
+ return JSON.parse(await build(v, opts));
174
+ }
175
+ function golden(name, got) {
176
+ const file = path_1.default.join(__dirname, 'testdata', name);
177
+ if (UPDATE)
178
+ fs_1.default.writeFileSync(file, got);
179
+ expect(got).toBe(fs_1.default.readFileSync(file, 'utf8'));
180
+ }
181
+ function byURL(h) {
182
+ return new Map(h.log.entries.map((e) => [e.request.url, e]));
183
+ }
184
+ function entryFor(h, url) {
185
+ const e = byURL(h).get(url);
186
+ if (!e)
187
+ throw new Error(`no entry for ${url}`);
188
+ return e;
189
+ }
190
+ describe('buildHar', () => {
191
+ it('matches the golden file', async () => {
192
+ golden('session.har', await build(await session()));
193
+ });
194
+ it('leaves sensitive headers out unless asked', async () => {
195
+ const v = await session();
196
+ expect(await build(v)).not.toContain('test-token');
197
+ expect(await build(v, { secrets: true })).toContain('test-token');
198
+ });
199
+ it('selects a range and the pages its entries belong to', async () => {
200
+ // 1_150–1_950 ms → offsets 150–950: the XHR, the failed fetch and the
201
+ // document request of page B.
202
+ const h = await parse(await session(), { from: 150, to: 950 });
203
+ expect(h.log.entries.map((e) => e.request.url).join(' ')).toBe('http://localhost:7788/api/broken http://localhost:9/down http://localhost:7788/b.html');
204
+ expect(h.log.pages.map((p) => p.id).join(' ')).toBe('page_1 page_2');
205
+ });
206
+ it('converts deep mode records', async () => {
207
+ const got = await build(await deepSession());
208
+ golden('deep.har', got);
209
+ const h = JSON.parse(got);
210
+ const products = entryFor(h, 'http://localhost:7788/api/products');
211
+ expect(products.timings).toMatchObject({ dns: 2, connect: 3, ssl: -1, wait: 30 });
212
+ expect(products.time).toBe(42); // the recorded split's sum
213
+ expect([products.request.httpVersion, products.serverIPAddress]).toEqual(['HTTP/1.1', '127.0.0.1']);
214
+ expect(products.comment).toContain('Set-Cookie tabtape_demo rejected: SameSite=None requires Secure');
215
+ const cors = entryFor(h, 'http://127.0.0.1:7789/api/cors');
216
+ expect(cors.response.status).toBe(0);
217
+ expect(cors.comment).toContain('no Access-Control-Allow-Origin header');
218
+ expect(entryFor(h, 'http://localhost:7788/logo.svg')._fromCache).toBe('memory');
219
+ const ws = entryFor(h, 'ws://localhost:7788/echo');
220
+ expect(ws._resourceType).toBe('websocket');
221
+ expect((ws._webSocketMessages ?? []).map((m) => [m.type, m.opcode])).toEqual([
222
+ ['send', 1],
223
+ ['receive', 1],
224
+ ['receive', 2],
225
+ ]);
226
+ expect(got).not.toContain('sid=abc'); // cookie values stay out of the default (sanitized) HAR
227
+ });
228
+ it('lists cookies when secrets are kept', async () => {
229
+ const h = await parse(await deepSession(), { secrets: true });
230
+ const products = entryFor(h, 'http://localhost:7788/api/products');
231
+ expect(products.request.headers.map((x) => x.name)).toEqual(['accept', 'cookie']);
232
+ expect(products.request.cookies).toEqual([
233
+ { name: 'sid', value: 'abc' },
234
+ { name: 'theme', value: 'dark' },
235
+ ]);
236
+ expect(products.response.headers.map((x) => x.name)).toEqual(['content-type', 'set-cookie']);
237
+ expect(products.response.cookies).toEqual([
238
+ { name: 'tabtape_demo', value: '1' },
239
+ { name: 'ok', value: '1' },
240
+ ]);
241
+ expect(entryFor(h, 'ws://localhost:7788/echo').request.cookies).toEqual([{ name: 'sid', value: 'abc' }]);
242
+ expect(h.log.comment).not.toContain('Sensitive headers');
243
+ });
244
+ it('names resource types and protocols and resolves redirects', async () => {
245
+ const res = (ts, id, url, more = {}) => qt.net(ts, { id, kind: 'resource', method: 'GET', url, ...more });
246
+ const h = await parse(await sessionWith(qt.net(T + 1, { id: 'a', kind: 'navigation', method: 'GET', url: 'http://app.test/old', status: 302, protocol: 'h2', resHeaders: { Location: '/new?x=1' } }), res(T + 2, 'b', 'http://app.test/site', { mime: 'text/css; charset=utf-8', protocol: 'h3-29' }), res(T + 3, 'c', 'http://app.test/app.JS?v=2', { protocol: 'HTTP/1.1' }), res(T + 4, 'd', 'http://app.test/font', { initiator: 'css', protocol: 'spdy' }),
247
+ // A malformed media type is ignored, as by mime.ParseMediaType.
248
+ res(T + 5, 'e', 'http://app.test/x.png', { mime: 'text/html; charset' }), res(T + 6, 'f', 'http://app.test/thing'),
249
+ // Go's url.Parse: an opaque URL has no path, so no extension.
250
+ res(T + 7, 'g', 'data:text/plain,hello.js')));
251
+ const types = h.log.entries.map((e) => [e.request.url, e._resourceType, e.request.httpVersion]);
252
+ expect(types).toEqual([
253
+ ['http://app.test/old', 'document', 'HTTP/2'],
254
+ ['http://app.test/site', 'stylesheet', 'HTTP/3'],
255
+ ['http://app.test/app.JS?v=2', 'script', 'HTTP/1.1'],
256
+ ['http://app.test/font', 'stylesheet', 'spdy'],
257
+ ['http://app.test/x.png', 'image', ''],
258
+ ['http://app.test/thing', 'other', ''],
259
+ ['data:text/plain,hello.js', 'other', ''],
260
+ ]);
261
+ expect(h.log.entries[0].response.redirectURL).toBe('http://app.test/new?x=1');
262
+ });
263
+ it('notes open sockets, blocked cookies and truncated bodies', async () => {
264
+ const h = await parse(await sessionWith(qt.net(T + 1, { id: 'w', kind: 'websocket', method: 'GET', url: 'wss://app.test/live', status: 101, pending: true, duration: 250 }), qt.ws(T + 2, 'w', 'recv', 'hi'), { ts: T + 3, type: 'ws', data: { conn: 'w', dir: 'recv', opcode: 1, size: 2, error: 'decode failed' } }, qt.net(T + 4, {
265
+ id: 'p',
266
+ kind: 'fetch',
267
+ method: 'put',
268
+ url: 'http://app.test/api?q=100%&a+b=c%20d',
269
+ status: 200,
270
+ duration: 20,
271
+ ttfb: 50,
272
+ reqBody: 'héllo',
273
+ resBody: 'é😀',
274
+ resBodyTruncated: true,
275
+ blockedCookies: [blocked('sid', 'SameSiteLax', 'Weird')],
276
+ })));
277
+ const ws = entryFor(h, 'wss://app.test/live');
278
+ expect(ws.comment).toBe('Still open when the recording stopped');
279
+ expect([ws.time, ws.timings.wait, ws.timings.receive]).toEqual([250, 0, 250]);
280
+ expect(ws._webSocketMessages).toEqual([{ type: 'receive', time: (T + 2) / 1000, opcode: 1, data: 'hi' }]); // not the failed frame
281
+ const p = entryFor(h, 'http://app.test/api?q=100%&a+b=c%20d');
282
+ expect(p.request.method).toBe('PUT');
283
+ expect(p.request.queryString).toEqual([
284
+ { name: 'q', value: '100%' }, // a malformed escape keeps the raw pair
285
+ { name: 'a b', value: 'c d' },
286
+ ]);
287
+ expect(p.request.bodySize).toBe(6); // bytes
288
+ expect(p.request.postData).toEqual({ mimeType: 'application/octet-stream', text: 'héllo' });
289
+ expect(p.response.content).toEqual({ size: 0, mimeType: 'x-unknown', text: 'é😀', comment: 'Truncated by tabtape after 2 characters' });
290
+ expect([p.timings.wait, p.timings.receive, p.time]).toEqual([20, 0, 20]); // ttfb beyond the duration is ignored
291
+ expect(p.comment).toBe('Cookie sid not sent: SameSite=Lax: not sent on cross-site requests [SameSiteLax]; Weird');
292
+ });
293
+ it('describes sessions where deep mode ended', async () => {
294
+ const h = await parse(await sessionWith(qt.net(T + 1, { id: 'x', kind: 'fetch', method: 'GET', url: 'http://app.test/a', source: 'cdp', resourceType: 'fetch' }), qt.net(T + 2, { id: 'y', kind: 'fetch', method: 'GET', url: 'http://app.test/b' })));
295
+ expect(h.log.comment?.startsWith('Recorded by tabtape in deep mode')).toBe(true);
296
+ expect(h.log.comment).toContain(' Deep mode ended during the recording: later requests were captured in the page');
297
+ expect(h.log.comment).toMatch(/Sensitive headers were left out: authorization, cookie, proxy-authorization, set-cookie, x-api-key\.$/);
298
+ });
299
+ it('names the browser and the creator', async () => {
300
+ const st = qt.testStore();
301
+ const uas = {
302
+ edge: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.3537.57',
303
+ firefox: 'Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0',
304
+ safari: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15',
305
+ other: 'curl/8.7.1',
306
+ };
307
+ const rd = new reader_1.Reader(st);
308
+ const got = {};
309
+ for (const [id, userAgent] of Object.entries(uas)) {
310
+ st.start({ id, startedAt: T, url: 'http://app.test/', title: 'App', userAgent });
311
+ qt.append(st, id, qt.rrMeta(T, 'http://app.test/', 800, 600), qt.rrFull(T));
312
+ got[id] = (await (0, har_1.buildHar)(await rd.view(id), { redact: new redact_1.RedactHeaders(), version: '' })).log.browser;
313
+ }
314
+ expect(got).toEqual({
315
+ edge: { name: 'Microsoft Edge', version: '141.0.3537.57' },
316
+ firefox: { name: 'Firefox', version: '131.0' },
317
+ safari: { name: 'Safari', version: '17.4' },
318
+ other: undefined,
319
+ });
320
+ const h = await (0, har_1.buildHar)(await rd.view('other'), { redact: new redact_1.RedactHeaders(), version: '' });
321
+ expect('browser' in h.log).toBe(false);
322
+ expect(h.log.creator).toEqual({ name: 'tabtape', version: 'dev' });
323
+ expect(h.log.pages).toEqual([]);
324
+ });
325
+ });
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.TABTAPE_VERSION = exports.TABTAPE_MOUNT = void 0;
7
+ exports.resolveTabtapeUiDist = resolveTabtapeUiDist;
8
+ exports.createTabtape = createTabtape;
9
+ const path_1 = __importDefault(require("path"));
10
+ const buffer_1 = require("./buffer/buffer");
11
+ const config_1 = require("./config");
12
+ const lock_1 = require("./lock");
13
+ const log_1 = require("./log");
14
+ const server_1 = require("./mcp/server");
15
+ const reader_1 = require("./query/reader");
16
+ const redact_1 = require("./redact");
17
+ const store_1 = require("./store/store");
18
+ const follow_1 = require("./api/follow");
19
+ const record_1 = require("./api/record");
20
+ const router_1 = require("./api/router");
21
+ const static_1 = require("./api/static");
22
+ const ws_1 = require("./api/ws");
23
+ var config_2 = require("./config");
24
+ Object.defineProperty(exports, "TABTAPE_MOUNT", { enumerable: true, get: function () { return config_2.TABTAPE_MOUNT; } });
25
+ Object.defineProperty(exports, "TABTAPE_VERSION", { enumerable: true, get: function () { return config_2.TABTAPE_VERSION; } });
26
+ /**
27
+ * Resolve the replay UI build ABSOLUTELY from the daemon's module directory
28
+ * (daemon/src in dev, daemon/dist in prod) — never against the process cwd
29
+ * (pm2 God-daemon-cwd bug class). Works for the published layout too
30
+ * (repoburg/daemon/dist -> repoburg/tabtape/ui/dist).
31
+ */
32
+ function resolveTabtapeUiDist(fromDir) {
33
+ return path_1.default.resolve(fromDir, '../../tabtape/ui/dist');
34
+ }
35
+ /** Opens the stores (repairing and trimming them) and builds the routes. */
36
+ function createTabtape(opts) {
37
+ const config = opts.config ?? (0, config_1.loadConfig)();
38
+ const mount = opts.mount ?? config_1.TABTAPE_MOUNT;
39
+ const logger = opts.logger ?? (0, log_1.consoleLogger)();
40
+ const lock = (0, lock_1.lockDir)(config.dataDir);
41
+ let st;
42
+ let buffers;
43
+ try {
44
+ st = store_1.Store.open({
45
+ dir: config.dataDir,
46
+ maxBytes: config.maxBytes,
47
+ segmentBytes: config.segmentBytes,
48
+ compress: config.compress,
49
+ logger,
50
+ });
51
+ }
52
+ catch (err) {
53
+ lock.release();
54
+ throw err;
55
+ }
56
+ try {
57
+ buffers = buffer_1.BufferStore.open({ dir: path_1.default.join(config.dataDir, 'buffer'), maxBytes: config.bufferBytes, logger });
58
+ }
59
+ catch (err) {
60
+ void st.close().finally(() => lock.release());
61
+ throw err;
62
+ }
63
+ const rd = new reader_1.Reader(st);
64
+ const redact = new redact_1.RedactHeaders(config.redactHeaders);
65
+ // Record links carry this token; the MCP tool get_record_link hands it
66
+ // out. A new one each start: links from an earlier run stop working.
67
+ const recordToken = (0, record_1.newRecordToken)();
68
+ const mcp = new server_1.TabtapeMcp(st, rd, {
69
+ version: config_1.TABTAPE_VERSION,
70
+ baseURL: opts.baseURL,
71
+ redact,
72
+ showSecrets: config.showSecrets,
73
+ token: config.mcpToken,
74
+ recordToken,
75
+ logger,
76
+ });
77
+ let stopNow;
78
+ const stop = new Promise((resolve) => {
79
+ stopNow = resolve;
80
+ });
81
+ const followers = new follow_1.Followers();
82
+ const router = (0, router_1.createApiRouter)({
83
+ st,
84
+ rd,
85
+ buffers,
86
+ version: config_1.TABTAPE_VERSION,
87
+ baseURL: opts.baseURL,
88
+ mount,
89
+ redact,
90
+ uiDist: opts.uiDist,
91
+ viewer: (0, static_1.viewerLoader)(opts.uiDist),
92
+ mcp,
93
+ recordToken,
94
+ followers,
95
+ stop,
96
+ logger,
97
+ keepAliveMs: opts.keepAliveMs,
98
+ });
99
+ const upgrades = new ws_1.FollowUpgrade({ st, followers, stop, mount, logger, keepAliveMs: opts.keepAliveMs });
100
+ const stats = st.stats();
101
+ logger.info('recording sessions', {
102
+ ui: `${opts.baseURL}/`,
103
+ mcp: `${opts.baseURL}/mcp`,
104
+ data: config.dataDir,
105
+ used: (0, config_1.formatSize)(stats.totalBytes),
106
+ max: (0, config_1.formatSize)(config.maxBytes),
107
+ compress: config.compress,
108
+ sessions: stats.sessions,
109
+ });
110
+ let closed = null;
111
+ return {
112
+ router,
113
+ mount,
114
+ config,
115
+ handlesUpgrade: (req) => upgrades.matches(req),
116
+ handleUpgrade: (req, socket, head) => upgrades.handle(req, socket, head),
117
+ close: () => {
118
+ if (!closed) {
119
+ closed = (async () => {
120
+ stopNow();
121
+ upgrades.close();
122
+ buffers.close();
123
+ try {
124
+ await st.close();
125
+ }
126
+ finally {
127
+ lock.release();
128
+ }
129
+ })();
130
+ }
131
+ return closed;
132
+ },
133
+ };
134
+ }
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const express_1 = __importDefault(require("express"));
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const http_1 = __importDefault(require("http"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const ws_1 = __importDefault(require("ws"));
11
+ const config_1 = require("./config");
12
+ const index_1 = require("./index");
13
+ const log_1 = require("./log");
14
+ const tmp_1 = require("./testing/tmp");
15
+ describe('loadConfig', () => {
16
+ it('reads the environment', () => {
17
+ const c = (0, config_1.loadConfig)({
18
+ REPOBURG_TABTAPE_DATA: '/x',
19
+ REPOBURG_TABTAPE_MAX_SIZE: '1GB',
20
+ REPOBURG_TABTAPE_COMPRESS: 'off',
21
+ REPOBURG_TABTAPE_MCP_SHOW_SECRETS: '1',
22
+ REPOBURG_TABTAPE_MCP_TOKEN: 't',
23
+ }, '/home/me');
24
+ expect(c).toMatchObject({ dataDir: '/x', maxBytes: 1 << 30, segmentBytes: 16 << 20, compress: 'off', showSecrets: true, mcpToken: 't' });
25
+ expect((0, config_1.loadConfig)({}, '/home/me').dataDir).toBe(path_1.default.join('/home/me', '.repoburg', 'tabtape'));
26
+ // Small caps keep eviction granular.
27
+ expect((0, config_1.loadConfig)({ REPOBURG_TABTAPE_MAX_SIZE: '1MB' }, '/h').segmentBytes).toBe(256 << 10);
28
+ expect(() => (0, config_1.loadConfig)({ REPOBURG_TABTAPE_COMPRESS: 'zstd' }, '/h')).toThrow(/REPOBURG_TABTAPE_COMPRESS/);
29
+ expect(() => (0, config_1.loadConfig)({ REPOBURG_TABTAPE_MAX_SIZE: 'lots' }, '/h')).toThrow(/REPOBURG_TABTAPE_MAX_SIZE/);
30
+ });
31
+ });
32
+ describe('createTabtape', () => {
33
+ let tabtape = null;
34
+ let server = null;
35
+ afterEach(async () => {
36
+ server?.closeAllConnections();
37
+ await new Promise((r) => (server ? server.close(() => r()) : r()));
38
+ await tabtape?.close();
39
+ tabtape = null;
40
+ server = null;
41
+ (0, tmp_1.cleanupTempDirs)();
42
+ });
43
+ it('mounts like the daemon and serves API, WebSocket and UI', async () => {
44
+ const dataDir = (0, tmp_1.tempDir)();
45
+ const uiDist = (0, tmp_1.tempDir)();
46
+ fs_1.default.mkdirSync(path_1.default.join(uiDist, 'app'));
47
+ fs_1.default.writeFileSync(path_1.default.join(uiDist, 'app', 'index.html'), '<html>replay</html>');
48
+ tabtape = (0, index_1.createTabtape)({
49
+ baseURL: 'http://127.0.0.1:9998/tabtape',
50
+ uiDist,
51
+ config: (0, config_1.loadConfig)({ REPOBURG_TABTAPE_DATA: dataDir }),
52
+ logger: log_1.silentLogger,
53
+ });
54
+ const app = (0, express_1.default)();
55
+ app.use(index_1.TABTAPE_MOUNT, tabtape.router);
56
+ server = http_1.default.createServer(app);
57
+ const tt = tabtape;
58
+ server.on('upgrade', (req, socket, head) => (tt.handlesUpgrade(req) ? tt.handleUpgrade(req, socket, head) : socket.destroy()));
59
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
60
+ const base = `http://127.0.0.1:${server.address().port}${index_1.TABTAPE_MOUNT}`;
61
+ const health = await (await fetch(`${base}/api/health`)).json();
62
+ expect(health.ok).toBe(true);
63
+ expect(health.dir).toBe(dataDir);
64
+ expect(health.buffers.maxBytes).toBe(100 << 20);
65
+ expect(await (await fetch(`${base}/`)).text()).toContain('replay');
66
+ const post = await fetch(`${base}/api/sessions/s1/events`, {
67
+ method: 'POST',
68
+ headers: { 'X-Tabtape-Client': 'test' },
69
+ body: '{"ts":1,"type":"event","data":{"name":"click"}}\n',
70
+ });
71
+ expect(post.status).toBe(200);
72
+ expect(fs_1.default.existsSync(path_1.default.join(dataDir, 'sessions', 's1', 'meta.json'))).toBe(true);
73
+ const ws = new ws_1.default(`${base.replace('http', 'ws')}/api/sessions/s1/events?follow=1`, { origin: new URL(base).origin });
74
+ const first = await new Promise((resolve, reject) => {
75
+ ws.once('message', (d) => resolve(d.toString()));
76
+ ws.once('error', reject);
77
+ });
78
+ expect(first).toContain('"sid":"s1"');
79
+ const closed = new Promise((r) => ws.once('close', (code) => r(code)));
80
+ await tabtape.close();
81
+ expect(await closed).toBe(1001);
82
+ });
83
+ it('keeps one process per data directory', async () => {
84
+ const dataDir = (0, tmp_1.tempDir)();
85
+ const open = () => (0, index_1.createTabtape)({ baseURL: 'http://127.0.0.1:9998/tabtape', uiDist: null, config: (0, config_1.loadConfig)({ REPOBURG_TABTAPE_DATA: dataDir }), logger: log_1.silentLogger });
86
+ const first = open();
87
+ expect(() => open()).toThrow(/already open/);
88
+ await first.close();
89
+ // A lock left behind by a process that is gone is taken over.
90
+ fs_1.default.writeFileSync(path_1.default.join(dataDir, '.lock'), '999999999');
91
+ tabtape = open();
92
+ expect(fs_1.default.readFileSync(path_1.default.join(dataDir, '.lock'), 'utf8')).toBe(String(process.pid));
93
+ // A live process (this test's parent) holds it: refused.
94
+ await tabtape.close();
95
+ fs_1.default.writeFileSync(path_1.default.join(dataDir, '.lock'), String(process.ppid));
96
+ expect(() => open()).toThrow(`in use by process ${process.ppid}`);
97
+ fs_1.default.rmSync(path_1.default.join(dataDir, '.lock'));
98
+ tabtape = null;
99
+ });
100
+ it('resolves the UI build from the daemon directory', () => {
101
+ expect((0, index_1.resolveTabtapeUiDist)('/repo/daemon/dist')).toBe(path_1.default.resolve('/repo/tabtape/ui/dist'));
102
+ expect((0, index_1.resolveTabtapeUiDist)('/repo/daemon/src')).toBe(path_1.default.resolve('/repo/tabtape/ui/dist'));
103
+ });
104
+ });
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.lockDir = lockDir;
7
+ /**
8
+ * One process per data directory. Opening the stores repairs segment tails
9
+ * and clears the buffer directory, which would corrupt the data of a daemon
10
+ * that is still using it (a second daemon started by mistake, a test harness
11
+ * pointed at the real directory).
12
+ */
13
+ const fs_1 = __importDefault(require("fs"));
14
+ const path_1 = __importDefault(require("path"));
15
+ const LOCK_NAME = '.lock';
16
+ function alive(pid) {
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ }
21
+ catch (err) {
22
+ return err.code === 'EPERM'; // exists, owned by someone else
23
+ }
24
+ }
25
+ /** Takes <dir>/.lock for this process; throws when a live process holds it. A stale lock is taken over. */
26
+ function lockDir(dir) {
27
+ fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o755 });
28
+ const file = path_1.default.join(dir, LOCK_NAME);
29
+ for (let attempt = 0;; attempt++) {
30
+ try {
31
+ fs_1.default.writeFileSync(file, String(process.pid), { flag: 'wx', mode: 0o644 });
32
+ break;
33
+ }
34
+ catch (err) {
35
+ if (err.code !== 'EEXIST' || attempt > 0)
36
+ throw err;
37
+ let pid = 0;
38
+ try {
39
+ pid = Number(fs_1.default.readFileSync(file, 'utf8').trim());
40
+ }
41
+ catch {
42
+ // unreadable: stale
43
+ }
44
+ if (pid === process.pid)
45
+ throw new Error(`${dir} is already open in this process`);
46
+ if (Number.isSafeInteger(pid) && pid > 0 && alive(pid)) {
47
+ throw new Error(`${dir} is in use by process ${pid} (another Repoburg daemon?)`);
48
+ }
49
+ fs_1.default.rmSync(file, { force: true }); // left behind by a process that is gone
50
+ }
51
+ }
52
+ let released = false;
53
+ return {
54
+ release: () => {
55
+ if (released)
56
+ return;
57
+ released = true;
58
+ try {
59
+ if (fs_1.default.readFileSync(file, 'utf8').trim() === String(process.pid))
60
+ fs_1.default.unlinkSync(file);
61
+ }
62
+ catch {
63
+ // already gone
64
+ }
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.silentLogger = void 0;
4
+ exports.consoleLogger = consoleLogger;
5
+ function format(prefix, msg, fields) {
6
+ if (!fields)
7
+ return `${prefix} ${msg}`;
8
+ const parts = Object.entries(fields).map(([k, v]) => {
9
+ const s = v instanceof Error ? v.message : typeof v === 'string' ? v : JSON.stringify(v);
10
+ return `${k}=${s}`;
11
+ });
12
+ return `${prefix} ${msg} ${parts.join(' ')}`;
13
+ }
14
+ /** Console logger; debug lines only with REPOBURG_TABTAPE_DEBUG=1. */
15
+ function consoleLogger(prefix = '[tabtape]', debugEnabled = process.env.REPOBURG_TABTAPE_DEBUG === '1') {
16
+ return {
17
+ debug: (msg, fields) => {
18
+ if (debugEnabled)
19
+ console.log(format(prefix, msg, fields));
20
+ },
21
+ info: (msg, fields) => console.log(format(prefix, msg, fields)),
22
+ warn: (msg, fields) => console.warn(format(prefix, msg, fields)),
23
+ error: (msg, fields) => console.error(format(prefix, msg, fields)),
24
+ };
25
+ }
26
+ /** Drops everything; for tests. */
27
+ exports.silentLogger = {
28
+ debug: () => { },
29
+ info: () => { },
30
+ warn: () => { },
31
+ error: () => { },
32
+ };