repoburg 1.3.255 → 1.3.256
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/daemon/dist/index.js +61 -3
- package/daemon/dist/staticServing.js +2 -2
- package/daemon/dist/tabtape/api/follow.js +174 -0
- package/daemon/dist/tabtape/api/http.js +221 -0
- package/daemon/dist/tabtape/api/record.js +149 -0
- package/daemon/dist/tabtape/api/router.js +519 -0
- package/daemon/dist/tabtape/api/router.spec.js +604 -0
- package/daemon/dist/tabtape/api/static.js +70 -0
- package/daemon/dist/tabtape/api/ws.js +166 -0
- package/daemon/dist/tabtape/buffer/buffer.js +347 -0
- package/daemon/dist/tabtape/buffer/buffer.spec.js +137 -0
- package/daemon/dist/tabtape/config.js +108 -0
- package/daemon/dist/tabtape/e2e-server.js +96 -0
- package/daemon/dist/tabtape/errors.js +77 -0
- package/daemon/dist/tabtape/export/export.js +331 -0
- package/daemon/dist/tabtape/export/export.spec.js +262 -0
- package/daemon/dist/tabtape/har/gostd.js +395 -0
- package/daemon/dist/tabtape/har/gostd.spec.js +139 -0
- package/daemon/dist/tabtape/har/har.js +582 -0
- package/daemon/dist/tabtape/har/har.spec.js +325 -0
- package/daemon/dist/tabtape/index.js +134 -0
- package/daemon/dist/tabtape/index.spec.js +104 -0
- package/daemon/dist/tabtape/lock.js +67 -0
- package/daemon/dist/tabtape/log.js +32 -0
- package/daemon/dist/tabtape/mcp/definitions.js +362 -0
- package/daemon/dist/tabtape/mcp/gofmt.js +227 -0
- package/daemon/dist/tabtape/mcp/params.js +345 -0
- package/daemon/dist/tabtape/mcp/params.spec.js +172 -0
- package/daemon/dist/tabtape/mcp/recordLink.js +122 -0
- package/daemon/dist/tabtape/mcp/server.js +411 -0
- package/daemon/dist/tabtape/mcp/server.spec.js +722 -0
- package/daemon/dist/tabtape/mcp/tools.js +1085 -0
- package/daemon/dist/tabtape/query/format.js +195 -0
- package/daemon/dist/tabtape/query/network.js +133 -0
- package/daemon/dist/tabtape/query/perf.js +93 -0
- package/daemon/dist/tabtape/query/query.spec.js +233 -0
- package/daemon/dist/tabtape/query/reader.js +602 -0
- package/daemon/dist/tabtape/query/types.js +274 -0
- package/daemon/dist/tabtape/redact.js +37 -0
- package/daemon/dist/tabtape/repro/lang.js +95 -0
- package/daemon/dist/tabtape/repro/repro.js +422 -0
- package/daemon/dist/tabtape/repro/repro.spec.js +210 -0
- package/daemon/dist/tabtape/sink.js +24 -0
- package/daemon/dist/tabtape/store/record.js +99 -0
- package/daemon/dist/tabtape/store/segment.js +267 -0
- package/daemon/dist/tabtape/store/store.js +846 -0
- package/daemon/dist/tabtape/store/store.spec.js +485 -0
- package/daemon/dist/tabtape/testing/querytest.js +119 -0
- package/daemon/dist/tabtape/testing/tmp.js +33 -0
- package/package.json +3 -2
- package/tabtape/extension/dist/background.js +8 -0
- package/tabtape/extension/dist/background.js.map +7 -0
- package/tabtape/extension/dist/bridge.js +3 -0
- package/tabtape/extension/dist/bridge.js.map +7 -0
- package/tabtape/extension/dist/icons/128.png +0 -0
- package/tabtape/extension/dist/icons/16.png +0 -0
- package/tabtape/extension/dist/icons/32.png +0 -0
- package/tabtape/extension/dist/icons/48.png +0 -0
- package/tabtape/extension/dist/manifest.json +28 -0
- package/tabtape/extension/dist/popup.css +62 -0
- package/tabtape/extension/dist/popup.html +34 -0
- package/tabtape/extension/dist/popup.js +4 -0
- package/tabtape/extension/dist/popup.js.map +7 -0
- package/tabtape/extension/dist/recorder.js +133 -0
- package/tabtape/extension/dist/recorder.js.map +1 -0
- package/tabtape/extension/dist/state.js +2 -0
- package/tabtape/extension/dist/state.js.map +1 -0
- package/tabtape/playwright/mcp.mjs +58 -0
- package/tabtape/playwright/tabtape.d.mts +37 -0
- package/tabtape/playwright/tabtape.mjs +146 -0
- package/tabtape/ui/dist/app/assets/index-Canv3Ljk.css +1 -0
- package/tabtape/ui/dist/app/assets/index-iWNSSrAE.js +145 -0
- package/tabtape/ui/dist/app/index.html +14 -0
- package/tabtape/ui/dist/export/viewer.css +2 -0
- package/tabtape/ui/dist/export/viewer.js +143 -0
|
@@ -0,0 +1,722 @@
|
|
|
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 http_1 = __importDefault(require("http"));
|
|
40
|
+
const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
|
|
41
|
+
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
|
42
|
+
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
43
|
+
const log_1 = require("../log");
|
|
44
|
+
const reader_1 = require("../query/reader");
|
|
45
|
+
const redact_1 = require("../redact");
|
|
46
|
+
const record_1 = require("../store/record");
|
|
47
|
+
const store_1 = require("../store/store");
|
|
48
|
+
const qt = __importStar(require("../testing/querytest"));
|
|
49
|
+
const tmp_1 = require("../testing/tmp");
|
|
50
|
+
const server_1 = require("./server");
|
|
51
|
+
const t0 = qt.T0;
|
|
52
|
+
const site = 'http://localhost:7788';
|
|
53
|
+
const base = 'http://127.0.0.1:9998/tabtape';
|
|
54
|
+
function demo(st, id, startedAt) {
|
|
55
|
+
qt.start(st, id, startedAt, site + '/', 'Acme Shop');
|
|
56
|
+
const u = site + '/';
|
|
57
|
+
qt.append(st, id, qt.pageView(startedAt, 'start', u, 'Acme Shop'), qt.rrMeta(startedAt + 10, u, 1440, 900), qt.rrFull(startedAt + 10), qt.click(startedAt + 1000, u, { tag: 'button', id: 'load-products', selector: 'button#load-products', text: 'Load products' }), qt.net(startedAt + 1100, { kind: 'fetch', method: 'GET', url: site + '/api/products', status: 200, duration: 8, resBody: '[{"name":"Trail shoes"}]' }), qt.net(startedAt + 2000, {
|
|
58
|
+
kind: 'fetch',
|
|
59
|
+
method: 'POST',
|
|
60
|
+
url: site + '/api/login',
|
|
61
|
+
status: 200,
|
|
62
|
+
reqHeaders: { authorization: 'Bearer test-token', 'content-type': 'application/json' },
|
|
63
|
+
reqBody: '{"email":"a@b.c","password":"********"}',
|
|
64
|
+
resBody: '{"ok":true}',
|
|
65
|
+
}), qt.click(startedAt + 3000, u, { tag: 'button', id: 'broken', selector: 'button#broken', text: 'Broken API' }), qt.net(startedAt + 3100, {
|
|
66
|
+
kind: 'fetch',
|
|
67
|
+
method: 'GET',
|
|
68
|
+
url: site + '/api/broken',
|
|
69
|
+
status: 500,
|
|
70
|
+
duration: 12,
|
|
71
|
+
resBody: '{"error":"kaboom","detail":"database connection refused"}',
|
|
72
|
+
}), qt.click(startedAt + 4000, u, { tag: 'button', id: 'throw', selector: 'button#throw', text: 'Throw error' }), qt.consoleRec(startedAt + 4010, {
|
|
73
|
+
level: 'error',
|
|
74
|
+
source: 'uncaught',
|
|
75
|
+
text: 'Boom from test page',
|
|
76
|
+
stack: 'Error: Boom from test page\n at index.html:88:40',
|
|
77
|
+
url: site + '/',
|
|
78
|
+
line: 88,
|
|
79
|
+
}), qt.consoleRec(startedAt + 4500, { level: 'log', source: 'console', text: 'hello from the page' }), qt.pageView(startedAt + 6000, 'load', site + '/b.html', 'Page B'), qt.rrInc(startedAt + 6500));
|
|
80
|
+
st.end(id, startedAt + 7000);
|
|
81
|
+
}
|
|
82
|
+
/** Run after each test, last added first: clients close before their servers. */
|
|
83
|
+
const cleanups = [];
|
|
84
|
+
afterEach(async () => {
|
|
85
|
+
while (cleanups.length)
|
|
86
|
+
await cleanups.pop()();
|
|
87
|
+
await qt.closeTestStores();
|
|
88
|
+
(0, tmp_1.cleanupTempDirs)();
|
|
89
|
+
});
|
|
90
|
+
function mcpFor(st, opts = {}) {
|
|
91
|
+
return new server_1.TabtapeMcp(st, new reader_1.Reader(st), {
|
|
92
|
+
version: 'test',
|
|
93
|
+
baseURL: base,
|
|
94
|
+
redact: new redact_1.RedactHeaders(),
|
|
95
|
+
showSecrets: false,
|
|
96
|
+
token: '',
|
|
97
|
+
recordToken: '',
|
|
98
|
+
logger: log_1.silentLogger,
|
|
99
|
+
timeZone: 'UTC',
|
|
100
|
+
now: () => t0 + 60000,
|
|
101
|
+
...opts,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** Serves mcp on an ephemeral port and returns its endpoint. */
|
|
105
|
+
async function listen(mcp) {
|
|
106
|
+
const srv = http_1.default.createServer((req, res) => {
|
|
107
|
+
void mcp.handle(req, res);
|
|
108
|
+
});
|
|
109
|
+
await new Promise((resolve) => srv.listen(0, '127.0.0.1', resolve));
|
|
110
|
+
cleanups.push(() => new Promise((resolve) => {
|
|
111
|
+
srv.closeAllConnections();
|
|
112
|
+
srv.close(() => resolve());
|
|
113
|
+
}));
|
|
114
|
+
return `http://127.0.0.1:${srv.address().port}/tabtape/mcp`;
|
|
115
|
+
}
|
|
116
|
+
async function connect(url, token) {
|
|
117
|
+
const client = new index_js_1.Client({ name: 'test', version: '1' });
|
|
118
|
+
const transport = new streamableHttp_js_1.StreamableHTTPClientTransport(new URL(url), token !== undefined ? { requestInit: { headers: { Authorization: `Bearer ${token}` } } } : undefined);
|
|
119
|
+
await client.connect(transport);
|
|
120
|
+
cleanups.push(() => client.close());
|
|
121
|
+
return client;
|
|
122
|
+
}
|
|
123
|
+
async function setup(opts = {}) {
|
|
124
|
+
const st = qt.testStore();
|
|
125
|
+
demo(st, 's-old', t0 - 3600000);
|
|
126
|
+
demo(st, 's1', t0);
|
|
127
|
+
const url = await listen(mcpFor(st, opts));
|
|
128
|
+
const client = await connect(url);
|
|
129
|
+
// From now on the client checks every result against its tool's output schema.
|
|
130
|
+
await client.listTools();
|
|
131
|
+
return { st, url, client };
|
|
132
|
+
}
|
|
133
|
+
async function call(e, name, args) {
|
|
134
|
+
const res = await e.client.callTool({ name, arguments: args });
|
|
135
|
+
const content = (res.content ?? []);
|
|
136
|
+
return {
|
|
137
|
+
text: content
|
|
138
|
+
.filter((c) => c.type === 'text')
|
|
139
|
+
.map((c) => c.text ?? '')
|
|
140
|
+
.join(''),
|
|
141
|
+
data: res.structuredContent ?? {},
|
|
142
|
+
isError: res.isError === true,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function raw(url, method, headers, body) {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const req = http_1.default.request(url, { method, headers, agent: false }, (res) => {
|
|
148
|
+
let data = '';
|
|
149
|
+
res.setEncoding('utf8');
|
|
150
|
+
res.on('data', (chunk) => (data += chunk));
|
|
151
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: data }));
|
|
152
|
+
});
|
|
153
|
+
req.on('error', reject);
|
|
154
|
+
req.end(body);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const PING = '{"jsonrpc":"2.0","id":1,"method":"ping"}';
|
|
158
|
+
/** The text of a resource's contents. */
|
|
159
|
+
const textOf = (c) => ('text' in c ? c.text : '');
|
|
160
|
+
describe('TabtapeMcp', () => {
|
|
161
|
+
it('lists the tools as read-only', async () => {
|
|
162
|
+
const e = await setup();
|
|
163
|
+
const res = await e.client.listTools();
|
|
164
|
+
const names = res.tools.map((t) => t.name);
|
|
165
|
+
expect(names).toEqual([
|
|
166
|
+
'get_console',
|
|
167
|
+
'get_context_at',
|
|
168
|
+
'get_network',
|
|
169
|
+
'get_record_link',
|
|
170
|
+
'get_repro_steps',
|
|
171
|
+
'get_request',
|
|
172
|
+
'get_session_summary',
|
|
173
|
+
'list_sessions',
|
|
174
|
+
]);
|
|
175
|
+
for (const tool of res.tools) {
|
|
176
|
+
expect([tool.name, tool.annotations]).toEqual([tool.name, { idempotentHint: false, openWorldHint: false, readOnlyHint: true }]);
|
|
177
|
+
expect(tool.outputSchema?.type).toBe('object');
|
|
178
|
+
expect(tool.inputSchema.additionalProperties).toBe(false);
|
|
179
|
+
}
|
|
180
|
+
const net = res.tools.find((t) => t.name === 'get_network');
|
|
181
|
+
expect(net?.inputSchema.required).toEqual(['session_id']);
|
|
182
|
+
expect(net?.inputSchema.properties?.include_bodies).toEqual({
|
|
183
|
+
type: 'boolean',
|
|
184
|
+
description: 'also return request and response bodies, cut to 2000 characters (use get_request for the full body)',
|
|
185
|
+
});
|
|
186
|
+
expect(e.client.getServerVersion()).toMatchObject({ name: 'tabtape', title: 'tabtape session recorder', version: 'test' });
|
|
187
|
+
expect(e.client.getInstructions()).toBe(server_1.INSTRUCTIONS);
|
|
188
|
+
expect(e.client.getServerCapabilities()).toMatchObject({ tools: {}, resources: {}, prompts: {} });
|
|
189
|
+
});
|
|
190
|
+
it('summarizes the latest session', async () => {
|
|
191
|
+
const e = await setup();
|
|
192
|
+
const { text, data, isError } = await call(e, 'get_session_summary', { session_id: 'latest' });
|
|
193
|
+
expect(isError).toBe(false);
|
|
194
|
+
const order = [
|
|
195
|
+
'Session s1',
|
|
196
|
+
'Page viewed /',
|
|
197
|
+
'Clicked "Load products" (button#load-products)',
|
|
198
|
+
'Clicked "Broken API"',
|
|
199
|
+
'GET http://localhost:7788/api/broken → 500',
|
|
200
|
+
'Uncaught error: Boom from test page at /:88',
|
|
201
|
+
'Page viewed /b.html',
|
|
202
|
+
];
|
|
203
|
+
let pos = 0;
|
|
204
|
+
for (const want of order) {
|
|
205
|
+
const i = text.indexOf(want, pos);
|
|
206
|
+
if (i < 0)
|
|
207
|
+
throw new Error(`summary is missing ${JSON.stringify(want)} after position ${pos}:\n${text}`);
|
|
208
|
+
pos = i;
|
|
209
|
+
}
|
|
210
|
+
expect(text).toContain(base + '/#/s/s1?t=3090');
|
|
211
|
+
expect(text).not.toContain('"node"');
|
|
212
|
+
expect(text).not.toContain('childNodes');
|
|
213
|
+
expect(data.items).toHaveLength(7);
|
|
214
|
+
expect(data.total).toBe(7);
|
|
215
|
+
expect(data.session).toMatchObject({
|
|
216
|
+
id: 's1',
|
|
217
|
+
startedAt: '2025-10-05T07:47:12Z',
|
|
218
|
+
durationMs: 7000,
|
|
219
|
+
title: 'Acme Shop',
|
|
220
|
+
browser: 'Chrome 141 · macOS',
|
|
221
|
+
pages: 2,
|
|
222
|
+
clicks: 3,
|
|
223
|
+
errors: 1,
|
|
224
|
+
failedRequests: 1,
|
|
225
|
+
replay: base + '/#/s/s1?t=0',
|
|
226
|
+
});
|
|
227
|
+
expect(text.startsWith('# Session s1\n"Acme Shop" · http://localhost:7788/\nStarted 2025-10-05 07:47:12 · duration 00:07 · Chrome 141 · macOS\n')).toBe(true);
|
|
228
|
+
expect(text).toContain('00:03.090 net-error GET http://localhost:7788/api/broken → 500 [n');
|
|
229
|
+
});
|
|
230
|
+
it('returns failed requests with bodies on request', async () => {
|
|
231
|
+
const e = await setup();
|
|
232
|
+
let { text, data } = await call(e, 'get_network', { session_id: 's1', status: 'failed', include_bodies: true });
|
|
233
|
+
expect(data.requests).toHaveLength(1);
|
|
234
|
+
expect(text).toContain('/api/broken');
|
|
235
|
+
expect(text).toContain('kaboom');
|
|
236
|
+
expect(data.requests[0].resBody).toContain('kaboom');
|
|
237
|
+
({ text } = await call(e, 'get_network', { session_id: 's1' }));
|
|
238
|
+
expect(text).not.toContain('kaboom');
|
|
239
|
+
({ text, data } = await call(e, 'get_network', { session_id: 's1', status: '>=600' }));
|
|
240
|
+
expect(text).toBe('0 request(s) in session s1:\nNo requests match.\n');
|
|
241
|
+
expect(data).toEqual({ requests: null, matched: 0 });
|
|
242
|
+
});
|
|
243
|
+
it('redacts secrets in request details', async () => {
|
|
244
|
+
const e = await setup();
|
|
245
|
+
const { data } = await call(e, 'get_network', { session_id: 's1', url_contains: 'LOGIN' });
|
|
246
|
+
const id = data.requests[0].id;
|
|
247
|
+
const { text, isError, data: detail } = await call(e, 'get_request', { session_id: 's1', request_id: id });
|
|
248
|
+
expect(isError).toBe(false);
|
|
249
|
+
expect(text).toContain('authorization: [redacted]');
|
|
250
|
+
expect(text).not.toContain('test-token');
|
|
251
|
+
expect(text).toContain('"password":"********"');
|
|
252
|
+
expect(detail.reqHeaders).toEqual({ authorization: '[redacted]', 'content-type': 'application/json' });
|
|
253
|
+
expect(detail.reqBodyLength).toBe(39);
|
|
254
|
+
const open = await setup({ showSecrets: true });
|
|
255
|
+
const shown = await call(open, 'get_request', { session_id: 's1', request_id: id });
|
|
256
|
+
expect(shown.text).toContain('Bearer test-token');
|
|
257
|
+
});
|
|
258
|
+
it('shows the context before an error', async () => {
|
|
259
|
+
const e = await setup();
|
|
260
|
+
const { data } = await call(e, 'get_console', { session_id: 's1', level: 'error' });
|
|
261
|
+
expect(data.messages).toHaveLength(1);
|
|
262
|
+
expect(data.messages[0]).toMatchObject({ level: 'error', source: 'uncaught', location: '/:88', stack: 'Error: Boom from test page\nat index.html:88:40' });
|
|
263
|
+
const id = data.messages[0].id;
|
|
264
|
+
let { text } = await call(e, 'get_context_at', { session_id: 's1', at: id });
|
|
265
|
+
const click = text.indexOf('Clicked "Throw error"');
|
|
266
|
+
const boom = text.indexOf('▶ ');
|
|
267
|
+
expect(click).toBeGreaterThanOrEqual(0);
|
|
268
|
+
expect(boom).toBeGreaterThan(click);
|
|
269
|
+
({ text } = await call(e, 'get_context_at', { session_id: 's1', at: '00:03.990', before: '1', after: '0.1' }));
|
|
270
|
+
expect(text).toContain('requested moment');
|
|
271
|
+
expect(text).not.toContain('Load products');
|
|
272
|
+
});
|
|
273
|
+
it('suggests recent sessions for an unknown id', async () => {
|
|
274
|
+
const e = await setup();
|
|
275
|
+
const { text, isError } = await call(e, 'get_session_summary', { session_id: 'abc123' });
|
|
276
|
+
expect(isError).toBe(true);
|
|
277
|
+
expect(text).toBe('session "abc123" not found. Recent sessions: s1, s-old (or use "latest")');
|
|
278
|
+
});
|
|
279
|
+
it('filters the list by time and writes repro steps', async () => {
|
|
280
|
+
const e = await setup();
|
|
281
|
+
let { text, data } = await call(e, 'list_sessions', { since: '30m' });
|
|
282
|
+
expect(data.sessions).toHaveLength(1);
|
|
283
|
+
expect(text).toContain('s1');
|
|
284
|
+
expect(text).toBe('1 recording(s), newest first:\n' +
|
|
285
|
+
'- s1 · 2025-10-05 07:47:12 · 00:07 · "Acme Shop" · http://localhost:7788/\n' +
|
|
286
|
+
' 2 pages, 3 clicks, 1 errors, 1 failed requests\n' +
|
|
287
|
+
` ${base}/#/s/s1?t=0\n`);
|
|
288
|
+
// The structured content is the Go server's JSON: keys sorted, empty fields left out.
|
|
289
|
+
expect(JSON.stringify(data)).toBe('{"matched":1,"sessions":[{"browser":"Chrome 141 · macOS","clicks":3,"durationMs":7000,"errors":1,"failedRequests":1,' +
|
|
290
|
+
`"id":"s1","pages":2,"replay":"${base}/#/s/s1?t=0","startedAt":"2025-10-05T07:47:12Z","title":"Acme Shop","url":"http://localhost:7788/"}]}`);
|
|
291
|
+
({ text, data } = await call(e, 'list_sessions', { limit: 1 }));
|
|
292
|
+
expect(text.startsWith('2 recording(s), newest first (showing 1; narrow with query, since or limit):\n')).toBe(true);
|
|
293
|
+
expect(data.matched).toBe(2);
|
|
294
|
+
({ text } = await call(e, 'get_repro_steps', { session_id: 's1' }));
|
|
295
|
+
expect(text).toContain('Clicked "Broken API"');
|
|
296
|
+
expect(text).toContain('**500**');
|
|
297
|
+
expect(text).toContain('#/s/s1?t=3090');
|
|
298
|
+
const empty = await call(e, 'get_repro_steps', { session_id: 's1', from: '00:05', to: '00:04' });
|
|
299
|
+
expect([empty.isError, empty.text]).toEqual([true, 'empty range: to must be after from']);
|
|
300
|
+
});
|
|
301
|
+
it('cuts large results', async () => {
|
|
302
|
+
const e = await setup();
|
|
303
|
+
const recs = [];
|
|
304
|
+
for (let i = 0; i < 2000; i++) {
|
|
305
|
+
recs.push(qt.net(t0 + 7000 + i, { kind: 'fetch', method: 'GET', url: `${site}/api/item/${i}?${'q'.repeat(40)}`, status: 200 }));
|
|
306
|
+
}
|
|
307
|
+
qt.append(e.st, 's1', ...recs);
|
|
308
|
+
const first = await call(e, 'get_network', { session_id: 's1', limit: 500 });
|
|
309
|
+
expect(Buffer.byteLength(first.text)).toBeLessThanOrEqual(30000);
|
|
310
|
+
expect(typeof first.data.next_cursor).toBe('string');
|
|
311
|
+
expect(first.text).toContain(`… more requests. Call again with cursor "${first.data.next_cursor}"`);
|
|
312
|
+
const next = await call(e, 'get_network', { session_id: 's1', limit: 500, cursor: first.data.next_cursor });
|
|
313
|
+
expect(next.text).not.toBe(first.text);
|
|
314
|
+
expect(Buffer.byteLength(next.text)).toBeLessThanOrEqual(30000);
|
|
315
|
+
expect(next.text).toContain(`, from #${Number(first.data.next_cursor) + 1}:`);
|
|
316
|
+
const bad = await call(e, 'get_network', { session_id: 's1', cursor: 'x' });
|
|
317
|
+
expect([bad.isError, bad.text]).toEqual([true, 'invalid cursor "x": pass next_cursor from the previous result']);
|
|
318
|
+
});
|
|
319
|
+
it('serves resources, the template and the prompt', async () => {
|
|
320
|
+
const e = await setup();
|
|
321
|
+
const res = await e.client.listResources();
|
|
322
|
+
expect(res.resources.map((r) => r.uri)).toEqual(['tabtape://session/s-old', 'tabtape://session/s1']);
|
|
323
|
+
expect(res.resources[1]).toMatchObject({
|
|
324
|
+
name: 's1',
|
|
325
|
+
title: 'Oct 5 07:47 · Acme Shop',
|
|
326
|
+
description: 'tabtape recording of http://localhost:7788/',
|
|
327
|
+
mimeType: 'text/markdown',
|
|
328
|
+
});
|
|
329
|
+
const read = await e.client.readResource({ uri: 'tabtape://session/s1' });
|
|
330
|
+
expect(read.contents[0]).toMatchObject({ uri: 'tabtape://session/s1', mimeType: 'text/markdown' });
|
|
331
|
+
expect(textOf(read.contents[0])).toContain('Broken API');
|
|
332
|
+
const latest = await e.client.readResource({ uri: 'tabtape://session/latest' });
|
|
333
|
+
expect(textOf(latest.contents[0])).toContain('# Session s1');
|
|
334
|
+
// The Go SDK answers unknown resources with invalid params (SEP-2164).
|
|
335
|
+
await expect(e.client.readResource({ uri: 'tabtape://session/nope' })).rejects.toMatchObject({
|
|
336
|
+
code: -32602,
|
|
337
|
+
data: { uri: 'tabtape://session/nope' },
|
|
338
|
+
});
|
|
339
|
+
await expect(e.client.readResource({ uri: 'file:///etc/passwd' })).rejects.toMatchObject({ code: -32602 });
|
|
340
|
+
const templates = await e.client.listResourceTemplates();
|
|
341
|
+
expect(templates.resourceTemplates).toEqual([
|
|
342
|
+
{
|
|
343
|
+
name: 'session',
|
|
344
|
+
title: 'tabtape recording',
|
|
345
|
+
description: 'Summary of a recorded session: pages, clicks, changes, errors and failed requests with replay links.',
|
|
346
|
+
mimeType: 'text/markdown',
|
|
347
|
+
uriTemplate: 'tabtape://session/{id}',
|
|
348
|
+
},
|
|
349
|
+
]);
|
|
350
|
+
const prompts = await e.client.listPrompts();
|
|
351
|
+
expect(prompts.prompts.map((p) => p.name)).toEqual(['debug_latest']);
|
|
352
|
+
const prompt = await e.client.getPrompt({ name: 'debug_latest' });
|
|
353
|
+
expect(prompt.messages).toHaveLength(1);
|
|
354
|
+
expect(prompt.messages[0].content).toMatchObject({ type: 'text' });
|
|
355
|
+
await expect(e.client.getPrompt({ name: 'other' })).rejects.toBeInstanceOf(types_js_1.McpError);
|
|
356
|
+
});
|
|
357
|
+
it('pages resources', async () => {
|
|
358
|
+
const st = qt.testStore();
|
|
359
|
+
for (let i = 0; i < 1001; i++)
|
|
360
|
+
st.start({ id: `r${String(i).padStart(4, '0')}`, startedAt: t0 + i });
|
|
361
|
+
const client = await connect(await listen(mcpFor(st)));
|
|
362
|
+
const first = await client.listResources();
|
|
363
|
+
expect(first.resources).toHaveLength(1000);
|
|
364
|
+
expect(typeof first.nextCursor).toBe('string');
|
|
365
|
+
const second = await client.listResources({ cursor: first.nextCursor });
|
|
366
|
+
expect(second.resources.map((r) => r.uri)).toEqual(['tabtape://session/r1000']);
|
|
367
|
+
expect(second.nextCursor).toBeUndefined();
|
|
368
|
+
});
|
|
369
|
+
it('rejects browser requests and checks the token', async () => {
|
|
370
|
+
const e = await setup();
|
|
371
|
+
const evil = await raw(e.url, 'POST', { Origin: 'https://evil.example', 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, PING);
|
|
372
|
+
expect([evil.status, evil.body]).toEqual([403, 'browser requests are not allowed\n']);
|
|
373
|
+
const st = qt.testStore();
|
|
374
|
+
const url = await listen(mcpFor(st, { token: 's3cret' }));
|
|
375
|
+
const anon = await raw(url, 'POST', { 'Content-Type': 'application/json' }, '{}');
|
|
376
|
+
expect([anon.status, anon.headers['www-authenticate'], anon.body]).toEqual([401, 'Bearer realm="tabtape"', 'missing or wrong bearer token\n']);
|
|
377
|
+
const wrong = await raw(url, 'POST', { 'Content-Type': 'application/json', Authorization: 'Bearer s3cre' }, '{}');
|
|
378
|
+
expect(wrong.status).toBe(401);
|
|
379
|
+
const lower = await raw(url, 'POST', { 'Content-Type': 'application/json', Authorization: 'bearer s3cret' }, '{}');
|
|
380
|
+
expect(lower.status).toBe(401);
|
|
381
|
+
const client = await connect(url, 's3cret');
|
|
382
|
+
await expect(client.listTools()).resolves.toBeDefined();
|
|
383
|
+
});
|
|
384
|
+
it('answers like the stateless Go SDK handler before reading messages', async () => {
|
|
385
|
+
const e = await setup();
|
|
386
|
+
const json = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' };
|
|
387
|
+
for (const method of ['GET', 'DELETE', 'PUT']) {
|
|
388
|
+
const r = await raw(e.url, method, { Accept: 'text/event-stream', 'Mcp-Session-Id': 'abc' });
|
|
389
|
+
expect([method, r.status, r.headers.allow, r.body]).toEqual([method, 405, 'POST', 'Method Not Allowed\n']);
|
|
390
|
+
}
|
|
391
|
+
const evilHost = await raw(e.url, 'POST', { ...json, Host: 'evil.example:9998' }, PING);
|
|
392
|
+
expect([evilHost.status, evilHost.body]).toEqual([403, 'Forbidden: invalid Host header "evil.example:9998"\n']);
|
|
393
|
+
for (const host of ['localhost:9998', '127.0.0.2', '[::1]:9998']) {
|
|
394
|
+
expect([host, (await raw(e.url, 'POST', { ...json, Host: host }, PING)).status]).toEqual([host, 200]);
|
|
395
|
+
}
|
|
396
|
+
const version = await raw(e.url, 'POST', { ...json, 'Mcp-Protocol-Version': '1999-01-01' }, PING);
|
|
397
|
+
expect([version.status, version.body.startsWith('Bad Request: Unsupported protocol version (supported versions: 2025-06-18,')]).toEqual([400, true]);
|
|
398
|
+
const noType = await raw(e.url, 'POST', { Accept: json.Accept }, PING);
|
|
399
|
+
expect([noType.status, noType.body]).toEqual([415, "Content-Type must be 'application/json'\n"]);
|
|
400
|
+
const noAccept = await raw(e.url, 'POST', { 'Content-Type': 'application/json' }, PING);
|
|
401
|
+
expect([noAccept.status, noAccept.body]).toEqual([400, "Accept must contain both 'application/json' and 'text/event-stream'\n"]);
|
|
402
|
+
const lastEvent = await raw(e.url, 'POST', { ...json, 'Last-Event-ID': '1' }, PING);
|
|
403
|
+
expect([lastEvent.status, lastEvent.body]).toEqual([400, "can't send Last-Event-ID for POST request\n"]);
|
|
404
|
+
const empty = await raw(e.url, 'POST', json, '');
|
|
405
|
+
expect([empty.status, empty.body]).toEqual([400, 'POST requires a non-empty body\n']);
|
|
406
|
+
const bad = await raw(e.url, 'POST', json, '{not json');
|
|
407
|
+
expect([bad.status, bad.body.startsWith('malformed payload: ')]).toEqual([400, true]);
|
|
408
|
+
const big = await raw(e.url, 'POST', json, ' '.repeat((4 << 20) + 1));
|
|
409
|
+
expect([big.status, big.body]).toEqual([413, 'request body exceeds 4194304 bytes\n']);
|
|
410
|
+
const batch = '[' + PING + ',{"jsonrpc":"2.0","id":2,"method":"ping"}]';
|
|
411
|
+
const lateBatch = await raw(e.url, 'POST', { ...json, 'Mcp-Protocol-Version': '2025-06-18' }, batch);
|
|
412
|
+
expect([lateBatch.status, lateBatch.body]).toEqual([400, 'JSON-RPC batching is not supported in 2025-06-18 and later (request version: 2025-06-18)\n']);
|
|
413
|
+
const oldBatch = await raw(e.url, 'POST', json, batch);
|
|
414
|
+
expect([oldBatch.status, JSON.parse(oldBatch.body)]).toEqual([
|
|
415
|
+
200,
|
|
416
|
+
[
|
|
417
|
+
{ jsonrpc: '2.0', id: 1, result: {} },
|
|
418
|
+
{ jsonrpc: '2.0', id: 2, result: {} },
|
|
419
|
+
],
|
|
420
|
+
]);
|
|
421
|
+
// Parameters, case and wildcards do not matter, and a stateless server answers without initialize.
|
|
422
|
+
const ping = await raw(e.url, 'POST', { 'Content-Type': 'Application/JSON; charset=utf-8', Accept: 'application/json;q=0.9, TEXT/*' }, PING);
|
|
423
|
+
expect([ping.status, ping.headers['content-type'], JSON.parse(ping.body)]).toEqual([200, 'application/json', { jsonrpc: '2.0', id: 1, result: {} }]);
|
|
424
|
+
expect((await raw(e.url, 'POST', { 'Content-Type': 'application/json', Accept: '*/*' }, PING)).status).toBe(200);
|
|
425
|
+
});
|
|
426
|
+
it('validates tool arguments', async () => {
|
|
427
|
+
const e = await setup();
|
|
428
|
+
const missing = await call(e, 'get_session_summary', {});
|
|
429
|
+
expect(missing).toMatchObject({ isError: true, text: 'validating "arguments": validating root: required: missing properties: ["session_id"]' });
|
|
430
|
+
const extra = await call(e, 'list_sessions', { querry: 'x' });
|
|
431
|
+
expect(extra).toMatchObject({ isError: true, text: 'validating "arguments": validating root: unexpected additional properties ["querry"]' });
|
|
432
|
+
const wrongType = await call(e, 'list_sessions', { limit: '5', querry: 'x' }); // types are checked first
|
|
433
|
+
expect(wrongType).toMatchObject({
|
|
434
|
+
isError: true,
|
|
435
|
+
text: 'validating "arguments": validating root: validating /properties/limit: type: 5 has type "string", want "integer"',
|
|
436
|
+
});
|
|
437
|
+
const fraction = await call(e, 'get_request', { session_id: 's1', request_id: 'x', body_offset: 1.5 });
|
|
438
|
+
expect(fraction.text).toBe('validating "arguments": validating root: validating /properties/body_offset: type: 1.5 has type "number", want "integer"');
|
|
439
|
+
const nullValue = await call(e, 'get_network', { session_id: null });
|
|
440
|
+
expect(nullValue.text).toBe('validating "arguments": validating root: validating /properties/session_id: type: <invalid reflect.Value> has type "null", want "string"');
|
|
441
|
+
const big = await call(e, 'list_sessions', { query: 1e7 });
|
|
442
|
+
expect(big.text).toBe('validating "arguments": validating root: validating /properties/query: type: 1e+07 has type "integer", want "string"');
|
|
443
|
+
const several = await call(e, 'get_request', { b: 1, a: 2 });
|
|
444
|
+
expect(several.text).toBe('validating "arguments": validating root: unexpected additional properties ["a" "b"]');
|
|
445
|
+
await expect(e.client.callTool({ name: 'nope', arguments: {} })).rejects.toMatchObject({ code: -32602 });
|
|
446
|
+
const unknown = await call(e, 'get_request', { session_id: 's1', request_id: ' nope ' });
|
|
447
|
+
expect(unknown.text).toBe('request " nope " not found in session s1; ids come from get_network or get_session_summary');
|
|
448
|
+
const badAt = await call(e, 'get_context_at', { session_id: 's1', at: 'soon' });
|
|
449
|
+
expect(badAt.text).toBe('cannot find "soon": pass a time like 01:23.4 or an id from another tool');
|
|
450
|
+
const badKind = await call(e, 'get_network', { session_id: 's1', kind: 'Image' });
|
|
451
|
+
expect(badKind.text).toBe('unknown kind "Image"; use fetch, xhr, beacon, api, document, resource or websocket');
|
|
452
|
+
const badLevel = await call(e, 'get_console', { session_id: 's1', level: 'fatal' });
|
|
453
|
+
expect(badLevel.text).toBe('unknown level "fatal"; use error, warn, warn+, info, log, debug or trace');
|
|
454
|
+
});
|
|
455
|
+
it('resolves latest to the recording in progress', async () => {
|
|
456
|
+
const st = qt.testStore();
|
|
457
|
+
demo(st, 's-done', t0);
|
|
458
|
+
qt.start(st, 's-live', t0 - 60000, site + '/', 'Live');
|
|
459
|
+
let now = t0 + 60000;
|
|
460
|
+
const client = await connect(await listen(mcpFor(st, { now: () => now })));
|
|
461
|
+
qt.append(st, 's-live', qt.pageView(t0, 'start', site + '/', 'Live'));
|
|
462
|
+
// Written just now (the store clock is Date.now, so look from there).
|
|
463
|
+
now = Date.now() + 1000;
|
|
464
|
+
let res = await client.callTool({ name: 'get_session_summary', arguments: { session_id: 'LATEST' } });
|
|
465
|
+
expect(JSON.stringify(res.structuredContent)).toContain('"id":"s-live"');
|
|
466
|
+
expect(JSON.stringify(res.structuredContent)).toContain('"live":true');
|
|
467
|
+
now = Date.now() + 31000;
|
|
468
|
+
res = await client.callTool({ name: 'get_session_summary', arguments: { session_id: '' } });
|
|
469
|
+
expect(JSON.stringify(res.structuredContent)).toContain('"id":"s-done"');
|
|
470
|
+
const empty = await connect(await listen(mcpFor(qt.testStore())));
|
|
471
|
+
const none = await empty.callTool({ name: 'list_sessions', arguments: {} });
|
|
472
|
+
expect(none.content[0].text).toBe('No recordings match. Start one from the tabtape extension (⌥⇧R).:\n');
|
|
473
|
+
expect(none.structuredContent).toEqual({ sessions: null, matched: 0 });
|
|
474
|
+
const latest = await empty.callTool({ name: 'get_session_summary', arguments: { session_id: 'latest' } });
|
|
475
|
+
expect(latest.content[0].text).toBe('there are no recordings yet: start one from the tabtape extension');
|
|
476
|
+
const byId = await empty.callTool({ name: 'get_session_summary', arguments: { session_id: 'x' } });
|
|
477
|
+
expect(byId.content[0].text).toBe('session "x" not found, and there are no recordings yet');
|
|
478
|
+
});
|
|
479
|
+
it('reports the user, tracked events and performance', async () => {
|
|
480
|
+
const e = await setup();
|
|
481
|
+
const u = site + '/';
|
|
482
|
+
qt.append(e.st, 's1', qt.identify(t0 + 500, u, 'u_demo'), qt.track(t0 + 1500, u, 'demo_clicked', { n: 1 }), qt.loaf(t0 + 2990, 612, { url: site + '/slow.js', fn: 'handleClick', duration: 600 }), qt.vital(t0 + 3500, 'INP', 240, 'needs-improvement'), qt.vital(t0 + 3600, 'INP', 640, 'poor', { target: 'button#slow' }), qt.vital(t0 + 3700, 'CLS', 0.02, 'good'));
|
|
483
|
+
let { text, data } = await call(e, 'list_sessions', { user: 'U_DEMO' });
|
|
484
|
+
expect(data.sessions).toHaveLength(1);
|
|
485
|
+
expect(text).toContain('s1');
|
|
486
|
+
expect(text).toContain('user u_demo');
|
|
487
|
+
({ text } = await call(e, 'list_sessions', { user: 'nobody' }));
|
|
488
|
+
expect(text).toContain('No recordings match');
|
|
489
|
+
({ text } = await call(e, 'get_session_summary', { session_id: 's1' }));
|
|
490
|
+
for (const want of [
|
|
491
|
+
'User: u_demo',
|
|
492
|
+
'custom Event demo_clicked (n: 1)',
|
|
493
|
+
'identify Identified user u_demo',
|
|
494
|
+
'freeze Page froze for 612 ms (longest script: slow.js handleClick)',
|
|
495
|
+
'vital INP 640 ms (poor) on button#slow',
|
|
496
|
+
]) {
|
|
497
|
+
expect(text).toContain(want);
|
|
498
|
+
}
|
|
499
|
+
expect(text).not.toContain('240 ms');
|
|
500
|
+
expect(text).not.toContain('CLS');
|
|
501
|
+
({ text } = await call(e, 'get_context_at', { session_id: 's1', at: '00:03.000' }));
|
|
502
|
+
expect(text).toContain('CLS 0.02 (good)');
|
|
503
|
+
});
|
|
504
|
+
it('reports deep mode network data and store state', async () => {
|
|
505
|
+
const e = await setup();
|
|
506
|
+
const recs = [
|
|
507
|
+
qt.net(t0 + 1000, {
|
|
508
|
+
id: 'cdp-1',
|
|
509
|
+
kind: 'fetch',
|
|
510
|
+
method: 'GET',
|
|
511
|
+
url: 'http://127.0.0.1:7789/api/cors',
|
|
512
|
+
source: 'cdp',
|
|
513
|
+
error: 'net::ERR_FAILED',
|
|
514
|
+
corsErrorStatus: { corsError: 'MissingAllowOriginHeader', failedParameter: '' },
|
|
515
|
+
duration: 4,
|
|
516
|
+
}),
|
|
517
|
+
qt.net(t0 + 1100, {
|
|
518
|
+
id: 'cdp-2',
|
|
519
|
+
kind: 'fetch',
|
|
520
|
+
method: 'GET',
|
|
521
|
+
url: site + '/api/set-cookie',
|
|
522
|
+
status: 200,
|
|
523
|
+
source: 'cdp',
|
|
524
|
+
protocol: 'http/1.1',
|
|
525
|
+
remoteAddress: '127.0.0.1:7788',
|
|
526
|
+
duration: 12,
|
|
527
|
+
timing: { dns: 1, connect: 2, wait: 8, receive: 1 },
|
|
528
|
+
reqHeaders: { cookie: 'sid=secret-sid' },
|
|
529
|
+
blockedSetCookies: [{ name: 'tabtape_demo', reasons: ['SameSiteNoneInsecure'], domain: '', path: '' }],
|
|
530
|
+
}),
|
|
531
|
+
];
|
|
532
|
+
for (let i = 0; i < 150; i++) {
|
|
533
|
+
recs.push(qt.ws(t0 + 2000 + i, 'ws-1', i % 2 === 1 ? 'recv' : 'send', `{"n":${Math.floor(i / 2)}}`));
|
|
534
|
+
}
|
|
535
|
+
recs.push(qt.net(t0 + 1900, {
|
|
536
|
+
id: 'ws-1',
|
|
537
|
+
kind: 'websocket',
|
|
538
|
+
method: 'GET',
|
|
539
|
+
url: 'ws://localhost:7788/echo',
|
|
540
|
+
status: 101,
|
|
541
|
+
source: 'cdp',
|
|
542
|
+
duration: 900,
|
|
543
|
+
closedAt: t0 + 2800,
|
|
544
|
+
frames: { sent: 75, received: 75, sentBytes: 600, receivedBytes: 600, dropped: 0, dataOmitted: 0 },
|
|
545
|
+
}), qt.stateRec(t0 + 3000, { store: 'cart', kind: 'init', state: { cart: { items: [] } } }));
|
|
546
|
+
for (let i = 0; i < 12; i++) {
|
|
547
|
+
recs.push(qt.stateRec(t0 + 3100 + i * 10, {
|
|
548
|
+
store: 'cart',
|
|
549
|
+
kind: 'action',
|
|
550
|
+
action: 'cart/add',
|
|
551
|
+
changes: [{ path: `/cart/items/${i}`, value: { id: i }, deleted: false }],
|
|
552
|
+
}));
|
|
553
|
+
}
|
|
554
|
+
recs.push(qt.stateRec(t0 + 3300, { store: 'counter', kind: 'action', action: 'increment', burst: true }));
|
|
555
|
+
qt.append(e.st, 's1', ...recs);
|
|
556
|
+
let { text, data } = await call(e, 'get_session_summary', { session_id: 's1' });
|
|
557
|
+
for (const want of [
|
|
558
|
+
'Network recorded in deep mode',
|
|
559
|
+
'1 WebSocket connection',
|
|
560
|
+
'State: 13 action(s) in store(s) cart, counter',
|
|
561
|
+
'failed (CORS: the response has no Access-Control-Allow-Origin header)',
|
|
562
|
+
]) {
|
|
563
|
+
expect(text).toContain(want);
|
|
564
|
+
}
|
|
565
|
+
({ text, data } = await call(e, 'get_network', { session_id: 's1', kind: 'websocket' }));
|
|
566
|
+
expect(data.requests).toHaveLength(1);
|
|
567
|
+
expect(text).toContain('WS 101');
|
|
568
|
+
expect(text).toContain('75↑ 75↓ messages');
|
|
569
|
+
expect(data.requests[0].frames).toEqual({ sent: 75, received: 75, sentBytes: 600, receivedBytes: 600 });
|
|
570
|
+
const wsID = data.requests[0].id;
|
|
571
|
+
({ text, data } = await call(e, 'get_request', { session_id: 's1', request_id: wsID }));
|
|
572
|
+
expect(text).toContain('Messages 1–100 of 150');
|
|
573
|
+
expect(text).toContain('↑ 7 B {"n":0}');
|
|
574
|
+
expect(text).toContain('WebSocket closed after 900 ms · 75↑ 75↓ messages, 1.2 KB');
|
|
575
|
+
expect(data.next_frames_offset).toBe(100);
|
|
576
|
+
expect(data.messagesTotal).toBe(150);
|
|
577
|
+
expect(data.messages[0]).toMatchObject({ dir: 'send', opcode: 1, size: 7, data: '{"n":0}' });
|
|
578
|
+
({ text } = await call(e, 'get_request', { session_id: 's1', request_id: wsID, frames_offset: 100 }));
|
|
579
|
+
expect(text).toContain('Messages 101–150 of 150');
|
|
580
|
+
expect(text).toContain('{"n":74}');
|
|
581
|
+
expect(text).not.toContain('more messages');
|
|
582
|
+
({ text, data } = await call(e, 'get_request', { session_id: 's1', request_id: 'cdp-2' }));
|
|
583
|
+
for (const want of [
|
|
584
|
+
'Recorded in deep mode (Chrome DevTools Protocol) · http/1.1 · 127.0.0.1:7788',
|
|
585
|
+
'Timing: DNS 1 ms · connect 2 ms · wait 8 ms · download 1 ms',
|
|
586
|
+
'Set-Cookie rejected: tabtape_demo — SameSite=None requires Secure [SameSiteNoneInsecure]',
|
|
587
|
+
'cookie: [redacted]',
|
|
588
|
+
]) {
|
|
589
|
+
expect(text).toContain(want);
|
|
590
|
+
}
|
|
591
|
+
expect(text).not.toContain('secret-sid');
|
|
592
|
+
expect(data).toMatchObject({
|
|
593
|
+
recorded: 'deep mode (Chrome DevTools Protocol)',
|
|
594
|
+
protocol: 'http/1.1',
|
|
595
|
+
timing: { dns: 1, connect: 2, wait: 8, receive: 1 },
|
|
596
|
+
blockedSetCookies: [{ name: 'tabtape_demo', reasons: ['SameSiteNoneInsecure'] }],
|
|
597
|
+
});
|
|
598
|
+
({ text } = await call(e, 'get_request', { session_id: 's1', request_id: 'cdp-1' }));
|
|
599
|
+
expect(text).toContain('Why it failed: CORS: the response has no Access-Control-Allow-Origin header');
|
|
600
|
+
({ text, data } = await call(e, 'get_context_at', { session_id: 's1', at: '00:03.250' }));
|
|
601
|
+
expect(data.stateActions).toHaveLength(10);
|
|
602
|
+
expect(text).toContain('Last 10 store action(s) up to 00:03.250');
|
|
603
|
+
expect(text).toContain('/cart/items/11 = {"id":11}');
|
|
604
|
+
expect(text).not.toContain('/cart/items/1 =');
|
|
605
|
+
({ text } = await call(e, 'get_context_at', { session_id: 's1', at: '00:04' }));
|
|
606
|
+
expect(text).toContain('increment');
|
|
607
|
+
expect(text).toContain('type only');
|
|
608
|
+
});
|
|
609
|
+
it('pages long bodies', async () => {
|
|
610
|
+
const e = await setup();
|
|
611
|
+
const body = 'é'.repeat(25000) + 'END';
|
|
612
|
+
qt.append(e.st, 's1', qt.net(t0 + 5000, { id: 'big', kind: 'fetch', method: 'GET', url: site + '/api/big', status: 200, resBody: body }));
|
|
613
|
+
let { text, data } = await call(e, 'get_request', { session_id: 's1', request_id: 'big' });
|
|
614
|
+
expect(data).toMatchObject({ resBodyLength: 25003, next_body_offset: 20000 });
|
|
615
|
+
expect(data.resBody).toHaveLength(20000);
|
|
616
|
+
expect(text).toContain('Response body (characters 0–20000 of 25003):');
|
|
617
|
+
expect(text).toContain('… the body continues. Call again with body_offset 20000.');
|
|
618
|
+
({ text, data } = await call(e, 'get_request', { session_id: 's1', request_id: 'big', body_offset: 20000 }));
|
|
619
|
+
expect(data.next_body_offset).toBeUndefined();
|
|
620
|
+
expect(text).toContain('Response body (characters 20000–25003 of 25003):');
|
|
621
|
+
expect(data.resBody.endsWith('END')).toBe(true);
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
describe('get_record_link', () => {
|
|
625
|
+
it('builds start and stop links', async () => {
|
|
626
|
+
const e = await setup({ recordToken: 'tok-123' });
|
|
627
|
+
let { text, data, isError } = await call(e, 'get_record_link', { name: ' checkout: coupon ', url: site + '/cart?x=1' });
|
|
628
|
+
expect(isError).toBe(false);
|
|
629
|
+
const start = new URL(data.start);
|
|
630
|
+
expect(start.origin + start.pathname).toBe(base + '/record');
|
|
631
|
+
expect([start.searchParams.get('token'), start.searchParams.get('name'), start.searchParams.get('url')]).toEqual([
|
|
632
|
+
'tok-123',
|
|
633
|
+
'checkout: coupon',
|
|
634
|
+
site + '/cart?x=1',
|
|
635
|
+
]);
|
|
636
|
+
expect(data.start).toBe(`${base}/record?name=checkout%3A+coupon&token=tok-123&url=http%3A%2F%2Flocalhost%3A7788%2Fcart%3Fx%3D1`);
|
|
637
|
+
expect(data.stop).toBe(base + '/record/stop?token=tok-123');
|
|
638
|
+
for (const want of [
|
|
639
|
+
'Start: ' + data.start,
|
|
640
|
+
'Stop: ' + base + '/record/stop?token=tok-123',
|
|
641
|
+
'The tab then opens ' + site + '/cart?x=1',
|
|
642
|
+
'extension is not loaded',
|
|
643
|
+
'The links stop working when the Repoburg daemon restarts.',
|
|
644
|
+
]) {
|
|
645
|
+
expect(text).toContain(want);
|
|
646
|
+
}
|
|
647
|
+
// Without a url the tab stays on the confirmation page.
|
|
648
|
+
({ text, data } = await call(e, 'get_record_link'));
|
|
649
|
+
const bare = new URL(data.start);
|
|
650
|
+
expect([bare.searchParams.has('name'), bare.searchParams.has('url'), bare.searchParams.get('token')]).toEqual([false, false, 'tok-123']);
|
|
651
|
+
expect(text).toContain('"Recording <session id>"');
|
|
652
|
+
});
|
|
653
|
+
it('rejects other pages and works only with a token', async () => {
|
|
654
|
+
const e = await setup({ recordToken: 'tok' });
|
|
655
|
+
for (const u of ['javascript:alert(1)', 'chrome://settings', 'not a url', 'http://a b/', 'http://host:port/', 'http://x/%zz']) {
|
|
656
|
+
const res = await call(e, 'get_record_link', { url: u });
|
|
657
|
+
expect([u, res.isError, res.text]).toEqual([u, true, `url ${JSON.stringify(u)}: only http, https and file pages can be recorded`]);
|
|
658
|
+
}
|
|
659
|
+
for (const u of ['HTTPS://example.com/a?b=%zz', 'file:///tmp/page.html', 'http://[::1]:8080/']) {
|
|
660
|
+
expect([u, (await call(e, 'get_record_link', { url: u })).isError]).toEqual([u, false]);
|
|
661
|
+
}
|
|
662
|
+
const off = await setup();
|
|
663
|
+
const res = await call(off, 'get_record_link');
|
|
664
|
+
expect([res.isError, res.text]).toEqual([true, 'record links are turned off in this Repoburg daemon']);
|
|
665
|
+
});
|
|
666
|
+
it('marks sessions started by automation', async () => {
|
|
667
|
+
const e = await setup();
|
|
668
|
+
e.st.start({ id: 's-auto', startedAt: t0 + 1000, title: 'checkout: coupon', source: 'automation' });
|
|
669
|
+
const { text } = await call(e, 'list_sessions', { query: 'coupon' });
|
|
670
|
+
expect(text).toContain('"checkout: coupon"');
|
|
671
|
+
expect(text).toContain('started by automation');
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
/**
|
|
675
|
+
* MCP-R12: get_network on a 200 MB session in under 2 s, index built on the
|
|
676
|
+
* first call. Slow to set up, so opt-in:
|
|
677
|
+
*
|
|
678
|
+
* TABTAPE_LARGE=1 NODE_OPTIONS=--experimental-vm-modules npx jest src/tabtape/mcp -t 'large session'
|
|
679
|
+
*/
|
|
680
|
+
(process.env.TABTAPE_LARGE ? it : it.skip)('answers quickly on a large session', async () => {
|
|
681
|
+
const st = store_1.Store.open({ dir: (0, tmp_1.tempDir)(), maxBytes: 1 << 30, segmentBytes: 16 << 20, logger: log_1.silentLogger });
|
|
682
|
+
cleanups.push(() => st.close());
|
|
683
|
+
const snapshot = 'x'.repeat(60000);
|
|
684
|
+
let batch = [];
|
|
685
|
+
let total = 0;
|
|
686
|
+
let ts = t0;
|
|
687
|
+
for (let i = 0; total < 200 << 20; i++) {
|
|
688
|
+
let line;
|
|
689
|
+
if (i % 40 === 0) {
|
|
690
|
+
line = JSON.stringify({ ts, type: 'rrweb', data: { type: 2, data: { node: { text: snapshot } }, timestamp: ts } });
|
|
691
|
+
}
|
|
692
|
+
else if (i % 4 === 0) {
|
|
693
|
+
line = JSON.stringify({
|
|
694
|
+
ts,
|
|
695
|
+
type: 'network',
|
|
696
|
+
data: { kind: 'fetch', method: 'GET', url: `http://app.test/api/item/${i}`, status: 200 + ((i % 7) * 50), duration: 12, resBody: 'y'.repeat(2000) },
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
line = JSON.stringify({ ts, type: 'rrweb', data: { type: 3, data: { source: 0, adds: [{ node: { text: 'z'.repeat(1500) } }] }, timestamp: ts } });
|
|
701
|
+
}
|
|
702
|
+
batch.push((0, record_1.parseRecord)(line));
|
|
703
|
+
total += line.length;
|
|
704
|
+
ts += 20;
|
|
705
|
+
if (batch.length === 2000) {
|
|
706
|
+
st.append('big', batch);
|
|
707
|
+
batch = [];
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
st.append('big', batch);
|
|
711
|
+
const client = await connect(await listen(mcpFor(st)));
|
|
712
|
+
const sess = st.get('big');
|
|
713
|
+
for (const run of ['cold (builds the index)', 'warm']) {
|
|
714
|
+
const started = Date.now();
|
|
715
|
+
const res = await client.callTool({ name: 'get_network', arguments: { session_id: 'big', status: '>=400' } });
|
|
716
|
+
const elapsed = Date.now() - started;
|
|
717
|
+
const text = res.content[0].text;
|
|
718
|
+
console.log(`${run}: get_network on a ${(sess.bytes / (1 << 20)).toFixed(0)} MB session took ${elapsed} ms, ${text.length} chars`);
|
|
719
|
+
expect(elapsed).toBeLessThan(2000);
|
|
720
|
+
expect(Buffer.byteLength(text)).toBeLessThanOrEqual(30000);
|
|
721
|
+
}
|
|
722
|
+
}, 120000);
|