@tamsensedev/runner 0.2.1 → 0.3.0
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/README.md +3 -1
- package/dist/cli.js +229 -87
- package/dist/cli.js.map +1 -1
- package/dist/recorder.cjs +39 -11
- package/dist/recorder.cjs.map +1 -1
- package/library/agents/design-critic.md +1 -1
- package/library/agents/ui-tester.md +3 -2
- package/library/skills/ui-testing/SKILL.md +5 -5
- package/library/skills/ui-testing/references/local-stand.md +51 -0
- package/library/skills/ui-testing/references/recording.md +1 -0
- package/package.json +1 -1
package/dist/recorder.cjs
CHANGED
|
@@ -37,6 +37,7 @@ __export(recorder_exports, {
|
|
|
37
37
|
MAX_PURPOSE_LENGTH: () => MAX_PURPOSE_LENGTH,
|
|
38
38
|
MAX_TITLE_LENGTH: () => MAX_TITLE_LENGTH,
|
|
39
39
|
META_SUFFIX: () => META_SUFFIX,
|
|
40
|
+
READ_METHODS: () => READ_METHODS,
|
|
40
41
|
RECORDINGS_DIR_ENV: () => RECORDINGS_DIR_ENV,
|
|
41
42
|
REPLAY_SUFFIX: () => REPLAY_SUFFIX,
|
|
42
43
|
SERVER_ENV: () => SERVER_ENV,
|
|
@@ -44,6 +45,8 @@ __export(recorder_exports, {
|
|
|
44
45
|
checkCaption: () => checkCaption,
|
|
45
46
|
closeRecorded: () => closeRecorded,
|
|
46
47
|
installAutoRecording: () => installAutoRecording,
|
|
48
|
+
isExternalWrite: () => isExternalWrite,
|
|
49
|
+
isLocalHost: () => isLocalHost,
|
|
47
50
|
isTrackerIngest: () => isTrackerIngest,
|
|
48
51
|
mark: () => mark,
|
|
49
52
|
newRecordedContext: () => newRecordedContext,
|
|
@@ -72,6 +75,7 @@ var META_SUFFIX = ".json";
|
|
|
72
75
|
var MAX_TITLE_LENGTH = 60;
|
|
73
76
|
var MAX_PURPOSE_LENGTH = 160;
|
|
74
77
|
var DEFAULT_TRACKER_ORIGINS = ["https://my.tamsense.com"];
|
|
78
|
+
var READ_METHODS = ["GET", "HEAD", "OPTIONS"];
|
|
75
79
|
var BINDING = "__tamsenseRecord";
|
|
76
80
|
var STATE_KEY = /* @__PURE__ */ Symbol.for("tamsense.recorder");
|
|
77
81
|
var FLUSH_MS = 100;
|
|
@@ -101,6 +105,13 @@ function isTrackerIngest(url, tamsenseOrigins) {
|
|
|
101
105
|
}
|
|
102
106
|
return url.searchParams.has("key") || tamsenseOrigins.includes(url.origin);
|
|
103
107
|
}
|
|
108
|
+
function isLocalHost(hostname) {
|
|
109
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
110
|
+
return host === "localhost" || host.endsWith(".localhost") || host === "::1" || host === "0.0.0.0" || /^127(?:\.\d{1,3}){3}$/.test(host);
|
|
111
|
+
}
|
|
112
|
+
function isExternalWrite(method, url) {
|
|
113
|
+
return !READ_METHODS.includes(method.toUpperCase()) && !isLocalHost(url.hostname);
|
|
114
|
+
}
|
|
104
115
|
function trackerOrigins(env = import_node_process.default.env) {
|
|
105
116
|
const origins = new Set(DEFAULT_TRACKER_ORIGINS);
|
|
106
117
|
try {
|
|
@@ -187,7 +198,7 @@ var Session = class {
|
|
|
187
198
|
const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
|
188
199
|
this.file = (0, import_node_path.join)(dir, `${id}${REPLAY_SUFFIX}`);
|
|
189
200
|
this.metaFile = (0, import_node_path.join)(dir, `${id}${META_SUFFIX}`);
|
|
190
|
-
this.meta = { version: 1, id, ...recording.caption, startedAt: Date.now(), endedAt: null, url, marks: [], video: null, gaps: [], blockedTrackerRequests: 0, truncated: false, automatic: recording.automatic };
|
|
201
|
+
this.meta = { version: 1, id, ...recording.caption, startedAt: Date.now(), endedAt: null, url, marks: [], video: null, gaps: [], blockedTrackerRequests: 0, blockedExternalWrites: 0, truncated: false, automatic: recording.automatic };
|
|
191
202
|
(0, import_node_fs.writeFileSync)(this.file, "");
|
|
192
203
|
this.save();
|
|
193
204
|
}
|
|
@@ -231,10 +242,20 @@ var Session = class {
|
|
|
231
242
|
}
|
|
232
243
|
}
|
|
233
244
|
summary() {
|
|
234
|
-
const { title, purpose, video, marks, gaps, blockedTrackerRequests } = this.meta;
|
|
235
|
-
return { title, purpose, replay: this.file, video, marks: marks.length, gaps, blockedTrackerRequests };
|
|
245
|
+
const { title, purpose, video, marks, gaps, blockedTrackerRequests, blockedExternalWrites } = this.meta;
|
|
246
|
+
return { title, purpose, replay: this.file, video, marks: marks.length, gaps, blockedTrackerRequests, blockedExternalWrites };
|
|
236
247
|
}
|
|
237
248
|
};
|
|
249
|
+
function countBlocked(route, counter) {
|
|
250
|
+
try {
|
|
251
|
+
const session = state().pages.get(route.request().frame().page());
|
|
252
|
+
if (session) {
|
|
253
|
+
session.meta[counter] += 1;
|
|
254
|
+
session.save();
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
}
|
|
258
|
+
}
|
|
238
259
|
async function startSession(recording, page, injectNow) {
|
|
239
260
|
const current = state();
|
|
240
261
|
if (current.pages.has(page)) {
|
|
@@ -276,15 +297,19 @@ async function recordContext(context, caption, automatic = false) {
|
|
|
276
297
|
const recording = { caption: checked, automatic, sessions: [] };
|
|
277
298
|
current.contexts.set(context, recording);
|
|
278
299
|
const origins = trackerOrigins();
|
|
279
|
-
await context.route((url) =>
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
} catch {
|
|
300
|
+
await context.route((url) => !isLocalHost(url.hostname), async (route) => {
|
|
301
|
+
const request = route.request();
|
|
302
|
+
if (!isExternalWrite(request.method(), new URL(request.url()))) {
|
|
303
|
+
await route.continue().catch(() => {
|
|
304
|
+
});
|
|
305
|
+
return;
|
|
287
306
|
}
|
|
307
|
+
countBlocked(route, "blockedExternalWrites");
|
|
308
|
+
await route.abort("blockedbyclient").catch(() => {
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
await context.route((url) => isTrackerIngest(url, origins), async (route) => {
|
|
312
|
+
countBlocked(route, "blockedTrackerRequests");
|
|
288
313
|
await route.fulfill({ status: 200, contentType: "application/json", body: '{"ok":true}' });
|
|
289
314
|
});
|
|
290
315
|
await context.exposeBinding(BINDING, ({ page }, payload) => current.pages.get(page)?.receive(payload));
|
|
@@ -379,6 +404,7 @@ if (import_node_process.default.env[RECORDINGS_DIR_ENV]) {
|
|
|
379
404
|
MAX_PURPOSE_LENGTH,
|
|
380
405
|
MAX_TITLE_LENGTH,
|
|
381
406
|
META_SUFFIX,
|
|
407
|
+
READ_METHODS,
|
|
382
408
|
RECORDINGS_DIR_ENV,
|
|
383
409
|
REPLAY_SUFFIX,
|
|
384
410
|
SERVER_ENV,
|
|
@@ -386,6 +412,8 @@ if (import_node_process.default.env[RECORDINGS_DIR_ENV]) {
|
|
|
386
412
|
checkCaption,
|
|
387
413
|
closeRecorded,
|
|
388
414
|
installAutoRecording,
|
|
415
|
+
isExternalWrite,
|
|
416
|
+
isLocalHost,
|
|
389
417
|
isTrackerIngest,
|
|
390
418
|
mark,
|
|
391
419
|
newRecordedContext,
|
package/dist/recorder.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/recorder.ts","../../../node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.15_tsx@4.21.0_typescript@5.9.3_yaml@2.8.2/node_modules/tsup/assets/cjs_shims.js"],"sourcesContent":["import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'\r\nimport Module, { createRequire } from 'node:module'\r\nimport { basename, dirname, join } from 'node:path'\r\nimport process from 'node:process'\r\n\r\n// Запись браузера агента: rrweb пишет каждую страницу в свой файл NDJSON в папке запуска, по желанию ещё и видео.\r\n// Модуль самодостаточный: программа подключает его ко всем процессам Node агента через NODE_OPTIONS --require,\r\n// и тогда любой браузер Playwright записывается сам; скрипты агента импортируют его же, чтобы дать записи название и отметки\r\n\r\nexport const RECORDINGS_DIR_ENV = 'TAMSENSE_RECORDINGS_DIR'\r\nexport const SERVER_ENV = 'TAMSENSE_SERVER'\r\nexport const DEFAULT_TITLE_ENV = 'TAMSENSE_RECORDING_TITLE'\r\nexport const DEFAULT_PURPOSE_ENV = 'TAMSENSE_RECORDING_PURPOSE'\r\nexport const MARK_TAG = 'tamsense.mark'\r\nexport const REPLAY_SUFFIX = '.rrweb.ndjson'\r\nexport const META_SUFFIX = '.json'\r\nexport const MAX_TITLE_LENGTH = 60\r\nexport const MAX_PURPOSE_LENGTH = 160\r\n// трекер TAMsense по умолчанию шлёт сюда; адрес сервера из настроек программы добавляется к нему\r\nexport const DEFAULT_TRACKER_ORIGINS = ['https://my.tamsense.com']\r\n\r\nconst BINDING = '__tamsenseRecord'\r\nconst STATE_KEY = Symbol.for('tamsense.recorder')\r\nconst FLUSH_MS = 100\r\nconst MAX_SESSION_BYTES = 300 * 1024 * 1024\r\nconst GAP_CHECK_MS = 1500\r\nconst MAX_LABEL_LENGTH = 120\r\nconst RRWEB_META = 4\r\n\r\nexport interface RecordingCaption {\r\n title: string\r\n purpose: string\r\n}\r\n\r\nexport interface RecordedContextOptions extends RecordingCaption {\r\n // видео в придачу к записи: для canvas, карт, видео на странице, чужих iframe или когда его попросили\r\n video?: boolean\r\n // обычные настройки browser.newContext: viewport, storageState и другие\r\n context?: Record<string, unknown>\r\n}\r\n\r\nexport interface SessionMark {\r\n ts: number\r\n label: string\r\n}\r\n\r\n// Что знает программа о записи одной страницы; лежит рядом с файлом записи\r\nexport interface SessionMeta {\r\n version: 1\r\n id: string\r\n title: string\r\n purpose: string\r\n startedAt: number\r\n endedAt: number | null\r\n url: string\r\n marks: SessionMark[]\r\n video: string | null\r\n gaps: string[]\r\n blockedTrackerRequests: number\r\n truncated: boolean\r\n // записано само, без названия от агента\r\n automatic: boolean\r\n}\r\n\r\nexport interface RecordingSummary {\r\n title: string\r\n purpose: string\r\n replay: string\r\n video: string | null\r\n marks: number\r\n gaps: string[]\r\n blockedTrackerRequests: number\r\n}\r\n\r\ninterface PlaywrightVideo {\r\n path: () => Promise<string>\r\n}\r\n\r\ninterface PlaywrightPage {\r\n url: () => string\r\n isClosed: () => boolean\r\n on: (event: 'close', listener: () => void) => unknown\r\n evaluate: (fn: string | ((arg: never) => unknown), arg?: unknown) => Promise<unknown>\r\n video: () => PlaywrightVideo | null\r\n}\r\n\r\ninterface PlaywrightRoute {\r\n fulfill: (response: { status: number, contentType: string, body: string }) => Promise<void>\r\n request: () => { frame: () => { page: () => PlaywrightPage } }\r\n}\r\n\r\nexport interface PlaywrightContext {\r\n pages: () => PlaywrightPage[]\r\n on: (event: 'page' | 'close', listener: (page: PlaywrightPage) => void) => unknown\r\n exposeBinding: (name: string, callback: (source: { page: PlaywrightPage }, payload: string) => unknown) => Promise<void>\r\n addInitScript: (script: { content: string }) => Promise<void>\r\n route: (url: (url: URL) => boolean, handler: (route: PlaywrightRoute) => Promise<void>) => Promise<void>\r\n close: (options?: unknown) => Promise<void>\r\n}\r\n\r\nexport interface PlaywrightBrowser {\r\n newContext: (options?: Record<string, unknown>) => Promise<PlaywrightContext>\r\n}\r\n\r\ninterface Instrumentation {\r\n addListener: (listener: { runAfterCreateBrowserContext: (context: PlaywrightContext) => Promise<void> }) => void\r\n}\r\n\r\ninterface ContextRecording {\r\n caption: RecordingCaption\r\n automatic: boolean\r\n sessions: Session[]\r\n}\r\n\r\ninterface RecorderState {\r\n contexts: WeakMap<PlaywrightContext, ContextRecording>\r\n pages: WeakMap<PlaywrightPage, Session>\r\n instrumented: WeakSet<object>\r\n script: string | null\r\n hooked: boolean\r\n}\r\n\r\nfunction state(): RecorderState {\r\n const holder = globalThis as { [STATE_KEY]?: RecorderState }\r\n holder[STATE_KEY] ??= { contexts: new WeakMap(), pages: new WeakMap(), instrumented: new WeakSet(), script: null, hooked: false }\r\n return holder[STATE_KEY]\r\n}\r\n\r\nfunction recordingsDir(): string {\r\n const dir = process.env[RECORDINGS_DIR_ENV]\r\n if (!dir) {\r\n throw new Error(`${RECORDINGS_DIR_ENV} is not set: recordings work only inside a TAMsense run`)\r\n }\r\n return dir\r\n}\r\n\r\nfunction warn(text: string): void {\r\n process.stderr.write(`[tamsense recorder] ${text}\\n`)\r\n}\r\n\r\n// Правило блокировки трекера TAMsense: запрос на адрес, который кончается на /api/events, с ключом проекта в ?key=\r\n// или к серверу TAMsense. Так записи агента не попадают в аналитику и недельный разбор\r\nexport function isTrackerIngest(url: URL, tamsenseOrigins: readonly string[]): boolean {\r\n if (!/\\/api\\/events\\/?$/.test(url.pathname)) {\r\n return false\r\n }\r\n return url.searchParams.has('key') || tamsenseOrigins.includes(url.origin)\r\n}\r\n\r\nexport function trackerOrigins(env: Record<string, string | undefined> = process.env): string[] {\r\n const origins = new Set(DEFAULT_TRACKER_ORIGINS)\r\n try {\r\n if (env[SERVER_ENV]) {\r\n origins.add(new URL(env[SERVER_ENV]).origin)\r\n }\r\n }\r\n catch {}\r\n return [...origins]\r\n}\r\n\r\nexport function checkCaption(caption: Partial<RecordingCaption> | undefined): RecordingCaption {\r\n const title = typeof caption?.title === 'string' ? caption.title.trim() : ''\r\n const purpose = typeof caption?.purpose === 'string' ? caption.purpose.trim() : ''\r\n if (!title || !purpose) {\r\n throw new Error('Give it a title (what it shows, for example \"Design option 1\" or \"Feature testing, attempt 2\") and a purpose (one line on why it was made).')\r\n }\r\n if (title.length > MAX_TITLE_LENGTH || purpose.length > MAX_PURPOSE_LENGTH) {\r\n throw new Error(`Keep the title within ${MAX_TITLE_LENGTH} characters and the purpose within ${MAX_PURPOSE_LENGTH}.`)\r\n }\r\n return { title, purpose }\r\n}\r\n\r\nfunction clip(text: string, max: number): string {\r\n return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text\r\n}\r\n\r\n// Название записи, которую никто не назвал: из настроек запуска и имени скрипта, который открыл браузер\r\nexport function automaticCaption(env: Record<string, string | undefined> = process.env, script: string | undefined = process.argv[1]): RecordingCaption {\r\n const from = script ? `, opened by ${basename(script)}` : ''\r\n return {\r\n title: clip(env[DEFAULT_TITLE_ENV]?.trim() || 'Browser session', MAX_TITLE_LENGTH),\r\n purpose: clip(`${env[DEFAULT_PURPOSE_ENV]?.trim() || 'Recorded automatically while the agent used a browser'}${from}`, MAX_PURPOSE_LENGTH),\r\n }\r\n}\r\n\r\n// Сборка rrweb без модулей: UMD кладёт её в локальный module.exports, страница своего rrweb не видит и не теряет\r\nfunction rrwebSource(): string {\r\n const entry = createRequire(import.meta.url).resolve('rrweb')\r\n return readFileSync(join(dirname(entry), 'rrweb.umd.min.cjs'), 'utf8')\r\n}\r\n\r\n// Скрипт для каждой страницы: пишет только верхний документ, iframe того же сайта rrweb снимает сам.\r\n// События уходят в программу пачками через exposeBinding, пароли скрыты, как в rrweb по умолчанию\r\nexport function pageScript(rrweb: string): string {\r\n return `(() => {\r\nif (window.top !== window || window.__tamsenseRecording) return;\r\nwindow.__tamsenseRecording = true;\r\nconst rrweb = (() => { const module = { exports: {} }; const exports = module.exports; ${rrweb}\r\n; return module.exports; })();\r\nlet buffer = [];\r\nlet timer = null;\r\nconst send = (extra) => {\r\n clearTimeout(timer);\r\n timer = null;\r\n if (!buffer.length && !extra) return Promise.resolve();\r\n const events = buffer;\r\n buffer = [];\r\n return window.${BINDING}(JSON.stringify({ events, ...extra })).catch(() => {});\r\n};\r\nrrweb.record({\r\n emit(event) {\r\n buffer.push(event);\r\n if (!timer) timer = setTimeout(send, ${FLUSH_MS});\r\n },\r\n maskInputOptions: { password: true },\r\n});\r\nconst crossOrigin = (frame) => { try { return new URL(frame.src, location.href).origin !== location.origin; } catch { return false; } };\r\nconst checkGaps = () => {\r\n const gaps = [];\r\n if (document.querySelector('canvas')) gaps.push('canvas');\r\n if (document.querySelector('video')) gaps.push('video');\r\n if ([...document.querySelectorAll('iframe')].some(crossOrigin)) gaps.push('cross-origin iframe');\r\n if (gaps.length) send({ gaps });\r\n};\r\nwindow.__tamsenseFlush = () => send();\r\nwindow.__tamsenseMark = (label) => { rrweb.record.addCustomEvent('${MARK_TAG}', { label }); return send(); };\r\naddEventListener('pagehide', () => { send(); });\r\naddEventListener('load', () => setTimeout(checkGaps, ${GAP_CHECK_MS}));\r\n})();`\r\n}\r\n\r\nfunction script(): string {\r\n const current = state()\r\n current.script ??= pageScript(rrwebSource())\r\n return current.script\r\n}\r\n\r\nclass Session {\r\n readonly file: string\r\n readonly metaFile: string\r\n readonly meta: SessionMeta\r\n private bytes = 0\r\n\r\n constructor(dir: string, recording: ContextRecording, url: string) {\r\n const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`\r\n this.file = join(dir, `${id}${REPLAY_SUFFIX}`)\r\n this.metaFile = join(dir, `${id}${META_SUFFIX}`)\r\n this.meta = { version: 1, id, ...recording.caption, startedAt: Date.now(), endedAt: null, url, marks: [], video: null, gaps: [], blockedTrackerRequests: 0, truncated: false, automatic: recording.automatic }\r\n writeFileSync(this.file, '')\r\n this.save()\r\n }\r\n\r\n save(): void {\r\n writeFileSync(this.metaFile, `${JSON.stringify(this.meta, null, 2)}\\n`)\r\n }\r\n\r\n receive(payload: string): void {\r\n let batch: { events?: unknown[], gaps?: string[] }\r\n try {\r\n batch = JSON.parse(payload) as typeof batch\r\n }\r\n catch {\r\n return\r\n }\r\n if (batch.gaps?.length) {\r\n this.meta.gaps = [...new Set([...this.meta.gaps, ...batch.gaps])]\r\n this.save()\r\n }\r\n const page = (batch.events as Array<{ type?: unknown, data?: { href?: unknown } }> | undefined)?.find(event => event.type === RRWEB_META && typeof event.data?.href === 'string' && event.data.href !== 'about:blank')\r\n if (page && this.meta.url === 'about:blank') {\r\n this.meta.url = String(page.data!.href)\r\n this.save()\r\n }\r\n const lines = (batch.events ?? []).map(event => JSON.stringify(event)).join('\\n')\r\n if (!lines || this.meta.truncated) {\r\n return\r\n }\r\n if (this.bytes + lines.length > MAX_SESSION_BYTES) {\r\n this.meta.truncated = true\r\n this.save()\r\n return\r\n }\r\n this.bytes += lines.length + 1\r\n appendFileSync(this.file, `${lines}\\n`)\r\n }\r\n\r\n end(): void {\r\n if (this.meta.endedAt === null) {\r\n this.meta.endedAt = Date.now()\r\n this.save()\r\n }\r\n }\r\n\r\n summary(): RecordingSummary {\r\n const { title, purpose, video, marks, gaps, blockedTrackerRequests } = this.meta\r\n return { title, purpose, replay: this.file, video, marks: marks.length, gaps, blockedTrackerRequests }\r\n }\r\n}\r\n\r\nasync function startSession(recording: ContextRecording, page: PlaywrightPage, injectNow: boolean): Promise<void> {\r\n const current = state()\r\n if (current.pages.has(page)) {\r\n return\r\n }\r\n const session = new Session(recordingsDir(), recording, page.url())\r\n current.pages.set(page, session)\r\n recording.sessions.push(session)\r\n page.on('close', () => session.end())\r\n const video = page.video()\r\n if (video) {\r\n session.meta.video = await video.path().catch(() => null)\r\n session.save()\r\n }\r\n // страница уже была открыта до записи: скрипт для будущих страниц её не коснётся\r\n if (injectNow) {\r\n await page.evaluate(script()).catch(() => {})\r\n }\r\n}\r\n\r\nasync function flush(context: PlaywrightContext): Promise<void> {\r\n await Promise.all(context.pages().filter(page => !page.isClosed()).map(page =>\r\n page.evaluate(() => (window as unknown as { __tamsenseFlush?: () => Promise<void> }).__tamsenseFlush?.(), undefined).catch(() => {}),\r\n ))\r\n}\r\n\r\n// Записывает контекст Playwright; второй вызов для того же контекста только меняет название и назначение записи.\r\n// automatic: контекст записан сам, название придумала программа\r\nexport async function recordContext(context: PlaywrightContext, caption: RecordingCaption, automatic = false): Promise<void> {\r\n const checked = checkCaption(caption)\r\n const current = state()\r\n const known = current.contexts.get(context)\r\n if (known) {\r\n Object.assign(known, { caption: checked, automatic })\r\n for (const session of known.sessions) {\r\n Object.assign(session.meta, checked, { automatic })\r\n session.save()\r\n }\r\n return\r\n }\r\n mkdirSync(recordingsDir(), { recursive: true })\r\n const recording: ContextRecording = { caption: checked, automatic, sessions: [] }\r\n current.contexts.set(context, recording)\r\n const origins = trackerOrigins()\r\n await context.route(url => isTrackerIngest(url, origins), async (route) => {\r\n try {\r\n const session = current.pages.get(route.request().frame().page())\r\n if (session) {\r\n session.meta.blockedTrackerRequests += 1\r\n session.save()\r\n }\r\n }\r\n catch {}\r\n await route.fulfill({ status: 200, contentType: 'application/json', body: '{\"ok\":true}' })\r\n })\r\n await context.exposeBinding(BINDING, ({ page }, payload) => current.pages.get(page)?.receive(payload))\r\n await context.addInitScript({ content: script() })\r\n context.on('page', page => void startSession(recording, page, false))\r\n context.on('close', () => recording.sessions.forEach(session => session.end()))\r\n for (const page of context.pages()) {\r\n await startSession(recording, page, true)\r\n }\r\n const close = context.close.bind(context)\r\n context.close = async (options?: unknown) => {\r\n await flush(context)\r\n return close(options)\r\n }\r\n}\r\n\r\n// Новый записанный контекст: название и назначение обязательны, video: true добавляет видео Playwright\r\nexport async function newRecordedContext(browser: PlaywrightBrowser, options: RecordedContextOptions): Promise<PlaywrightContext> {\r\n const caption = checkCaption(options)\r\n const extra = options.video ? { recordVideo: { dir: join(recordingsDir(), 'video') } } : {}\r\n const context = await browser.newContext({ ...options.context, ...extra })\r\n await recordContext(context, caption)\r\n return context\r\n}\r\n\r\n// Отметка шага: видна на шкале плеера и в списке отметок, клик по ней перематывает запись к этому месту\r\nexport async function mark(page: PlaywrightPage, label: string): Promise<void> {\r\n const text = clip(String(label ?? '').trim(), MAX_LABEL_LENGTH)\r\n const session = state().pages.get(page)\r\n if (!text || !session) {\r\n warn(text ? 'mark() was called for a page that is not recorded' : 'mark() needs a label')\r\n return\r\n }\r\n session.meta.marks.push({ ts: Date.now(), label: text })\r\n session.save()\r\n await page.evaluate(value => (window as unknown as { __tamsenseMark?: (label: string) => Promise<void> }).__tamsenseMark?.(value), text).catch(() => {})\r\n}\r\n\r\n// Закрывает записанный контекст и говорит, что записано; подсказывает видео, если записи не хватит\r\nexport async function closeRecorded(context: PlaywrightContext): Promise<RecordingSummary[]> {\r\n await context.close()\r\n const sessions = state().contexts.get(context)?.sessions ?? []\r\n const summaries = sessions.map(session => session.summary())\r\n for (const summary of summaries) {\r\n if (summary.gaps.length > 0 && !summary.video) {\r\n warn(`\"${summary.title}\" has ${summary.gaps.join(', ')}: the replay may not show it. Record it again with video: true.`)\r\n }\r\n }\r\n return summaries\r\n}\r\n\r\nfunction instrument(exports: unknown): void {\r\n const chromium = (exports as { chromium?: { launch?: unknown, _instrumentation?: Instrumentation } } | null)?.chromium\r\n const instrumentation = (exports as { _instrumentation?: Instrumentation } | null)?._instrumentation ?? chromium?._instrumentation\r\n const current = state()\r\n if (typeof chromium?.launch !== 'function' || typeof instrumentation?.addListener !== 'function' || current.instrumented.has(instrumentation)) {\r\n return\r\n }\r\n current.instrumented.add(instrumentation)\r\n instrumentation.addListener({\r\n runAfterCreateBrowserContext: async (context) => {\r\n try {\r\n if (!current.contexts.has(context)) {\r\n await recordContext(context, automaticCaption(), true)\r\n }\r\n }\r\n catch (error) {\r\n warn(`could not record this browser: ${error instanceof Error ? error.message : String(error)}`)\r\n }\r\n },\r\n })\r\n}\r\n\r\n// Сам цепляется к Playwright, как только его загрузят: агент не может забыть включить запись\r\nexport function installAutoRecording(): void {\r\n const current = state()\r\n if (current.hooked) {\r\n return\r\n }\r\n current.hooked = true\r\n const loader = Module as unknown as { _load: (request: string, parent: unknown, isMain: boolean) => unknown }\r\n const load = loader._load\r\n loader._load = function (this: unknown, ...args: [string, unknown, boolean]) {\r\n const exports = load.apply(this, args)\r\n try {\r\n instrument(exports)\r\n }\r\n catch {}\r\n return exports\r\n }\r\n}\r\n\r\nif (process.env[RECORDINGS_DIR_ENV]) {\r\n installAutoRecording()\r\n}\r\n","// Shim globals in cjs bundle\n// There's a weird bug that esbuild will always inject importMetaUrl\n// if we export it as `const importMetaUrl = ... __filename ...`\n// But using a function will not cause this issue\n\nconst getImportMetaUrl = () => \n typeof document === \"undefined\" \n ? new URL(`file:${__filename}`).href \n : (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT') \n ? document.currentScript.src \n : new URL(\"main.js\", document.baseURI).href;\n\nexport const importMetaUrl = /* @__PURE__ */ getImportMetaUrl()\n"],"mappings":"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","names":["process","script","exports","Module"]}
|
|
1
|
+
{"version":3,"sources":["../src/recorder.ts","../../../node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.15_tsx@4.21.0_typescript@5.9.3_yaml@2.8.2/node_modules/tsup/assets/cjs_shims.js"],"sourcesContent":["import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'\r\nimport Module, { createRequire } from 'node:module'\r\nimport { basename, dirname, join } from 'node:path'\r\nimport process from 'node:process'\r\n\r\n// Запись браузера агента: rrweb пишет каждую страницу в свой файл NDJSON в папке запуска, по желанию ещё и видео.\r\n// Модуль самодостаточный: программа подключает его ко всем процессам Node агента через NODE_OPTIONS --require,\r\n// и тогда любой браузер Playwright записывается сам; скрипты агента импортируют его же, чтобы дать записи название и отметки\r\n\r\nexport const RECORDINGS_DIR_ENV = 'TAMSENSE_RECORDINGS_DIR'\r\nexport const SERVER_ENV = 'TAMSENSE_SERVER'\r\nexport const DEFAULT_TITLE_ENV = 'TAMSENSE_RECORDING_TITLE'\r\nexport const DEFAULT_PURPOSE_ENV = 'TAMSENSE_RECORDING_PURPOSE'\r\nexport const MARK_TAG = 'tamsense.mark'\r\nexport const REPLAY_SUFFIX = '.rrweb.ndjson'\r\nexport const META_SUFFIX = '.json'\r\nexport const MAX_TITLE_LENGTH = 60\r\nexport const MAX_PURPOSE_LENGTH = 160\r\n// трекер TAMsense по умолчанию шлёт сюда; адрес сервера из настроек программы добавляется к нему\r\nexport const DEFAULT_TRACKER_ORIGINS = ['https://my.tamsense.com']\r\n\r\n// эти методы только читают; остальные к адресам вне компьютера отменяются\r\nexport const READ_METHODS = ['GET', 'HEAD', 'OPTIONS']\r\n\r\nconst BINDING = '__tamsenseRecord'\r\nconst STATE_KEY = Symbol.for('tamsense.recorder')\r\nconst FLUSH_MS = 100\r\nconst MAX_SESSION_BYTES = 300 * 1024 * 1024\r\nconst GAP_CHECK_MS = 1500\r\nconst MAX_LABEL_LENGTH = 120\r\nconst RRWEB_META = 4\r\n\r\nexport interface RecordingCaption {\r\n title: string\r\n purpose: string\r\n}\r\n\r\nexport interface RecordedContextOptions extends RecordingCaption {\r\n // видео в придачу к записи: для canvas, карт, видео на странице, чужих iframe или когда его попросили\r\n video?: boolean\r\n // обычные настройки browser.newContext: viewport, storageState и другие\r\n context?: Record<string, unknown>\r\n}\r\n\r\nexport interface SessionMark {\r\n ts: number\r\n label: string\r\n}\r\n\r\n// Что знает программа о записи одной страницы; лежит рядом с файлом записи\r\nexport interface SessionMeta {\r\n version: 1\r\n id: string\r\n title: string\r\n purpose: string\r\n startedAt: number\r\n endedAt: number | null\r\n url: string\r\n marks: SessionMark[]\r\n video: string | null\r\n gaps: string[]\r\n blockedTrackerRequests: number\r\n blockedExternalWrites: number\r\n truncated: boolean\r\n // записано само, без названия от агента\r\n automatic: boolean\r\n}\r\n\r\nexport interface RecordingSummary {\r\n title: string\r\n purpose: string\r\n replay: string\r\n video: string | null\r\n marks: number\r\n gaps: string[]\r\n blockedTrackerRequests: number\r\n blockedExternalWrites: number\r\n}\r\n\r\ninterface PlaywrightVideo {\r\n path: () => Promise<string>\r\n}\r\n\r\ninterface PlaywrightPage {\r\n url: () => string\r\n isClosed: () => boolean\r\n on: (event: 'close', listener: () => void) => unknown\r\n evaluate: (fn: string | ((arg: never) => unknown), arg?: unknown) => Promise<unknown>\r\n video: () => PlaywrightVideo | null\r\n}\r\n\r\ninterface PlaywrightRoute {\r\n fulfill: (response: { status: number, contentType: string, body: string }) => Promise<void>\r\n abort: (errorCode?: string) => Promise<void>\r\n continue: () => Promise<void>\r\n request: () => { method: () => string, url: () => string, frame: () => { page: () => PlaywrightPage } }\r\n}\r\n\r\nexport interface PlaywrightContext {\r\n pages: () => PlaywrightPage[]\r\n on: (event: 'page' | 'close', listener: (page: PlaywrightPage) => void) => unknown\r\n exposeBinding: (name: string, callback: (source: { page: PlaywrightPage }, payload: string) => unknown) => Promise<void>\r\n addInitScript: (script: { content: string }) => Promise<void>\r\n route: (url: (url: URL) => boolean, handler: (route: PlaywrightRoute) => Promise<void>) => Promise<void>\r\n close: (options?: unknown) => Promise<void>\r\n}\r\n\r\nexport interface PlaywrightBrowser {\r\n newContext: (options?: Record<string, unknown>) => Promise<PlaywrightContext>\r\n}\r\n\r\ninterface Instrumentation {\r\n addListener: (listener: { runAfterCreateBrowserContext: (context: PlaywrightContext) => Promise<void> }) => void\r\n}\r\n\r\ninterface ContextRecording {\r\n caption: RecordingCaption\r\n automatic: boolean\r\n sessions: Session[]\r\n}\r\n\r\ninterface RecorderState {\r\n contexts: WeakMap<PlaywrightContext, ContextRecording>\r\n pages: WeakMap<PlaywrightPage, Session>\r\n instrumented: WeakSet<object>\r\n script: string | null\r\n hooked: boolean\r\n}\r\n\r\nfunction state(): RecorderState {\r\n const holder = globalThis as { [STATE_KEY]?: RecorderState }\r\n holder[STATE_KEY] ??= { contexts: new WeakMap(), pages: new WeakMap(), instrumented: new WeakSet(), script: null, hooked: false }\r\n return holder[STATE_KEY]\r\n}\r\n\r\nfunction recordingsDir(): string {\r\n const dir = process.env[RECORDINGS_DIR_ENV]\r\n if (!dir) {\r\n throw new Error(`${RECORDINGS_DIR_ENV} is not set: recordings work only inside a TAMsense run`)\r\n }\r\n return dir\r\n}\r\n\r\nfunction warn(text: string): void {\r\n process.stderr.write(`[tamsense recorder] ${text}\\n`)\r\n}\r\n\r\n// Правило блокировки трекера TAMsense: запрос на адрес, который кончается на /api/events, с ключом проекта в ?key=\r\n// или к серверу TAMsense. Так записи агента не попадают в аналитику и недельный разбор\r\nexport function isTrackerIngest(url: URL, tamsenseOrigins: readonly string[]): boolean {\r\n if (!/\\/api\\/events\\/?$/.test(url.pathname)) {\r\n return false\r\n }\r\n return url.searchParams.has('key') || tamsenseOrigins.includes(url.origin)\r\n}\r\n\r\n// Своим считается только сам компьютер: localhost, *.localhost, адреса loopback и 0.0.0.0. Имена контейнеров браузер\r\n// на компьютере не видит, поэтому имя без точки тоже чужое\r\nexport function isLocalHost(hostname: string): boolean {\r\n const host = hostname.toLowerCase().replace(/^\\[|\\]$/g, '').replace(/\\.$/, '')\r\n return host === 'localhost' || host.endsWith('.localhost') || host === '::1' || host === '0.0.0.0' || /^127(?:\\.\\d{1,3}){3}$/.test(host)\r\n}\r\n\r\n// Запрос, который может что-то изменить у чужого сервиса: метод не только читает, а адрес вне этого компьютера\r\nexport function isExternalWrite(method: string, url: URL): boolean {\r\n return !READ_METHODS.includes(method.toUpperCase()) && !isLocalHost(url.hostname)\r\n}\r\n\r\nexport function trackerOrigins(env: Record<string, string | undefined> = process.env): string[] {\r\n const origins = new Set(DEFAULT_TRACKER_ORIGINS)\r\n try {\r\n if (env[SERVER_ENV]) {\r\n origins.add(new URL(env[SERVER_ENV]).origin)\r\n }\r\n }\r\n catch {}\r\n return [...origins]\r\n}\r\n\r\nexport function checkCaption(caption: Partial<RecordingCaption> | undefined): RecordingCaption {\r\n const title = typeof caption?.title === 'string' ? caption.title.trim() : ''\r\n const purpose = typeof caption?.purpose === 'string' ? caption.purpose.trim() : ''\r\n if (!title || !purpose) {\r\n throw new Error('Give it a title (what it shows, for example \"Design option 1\" or \"Feature testing, attempt 2\") and a purpose (one line on why it was made).')\r\n }\r\n if (title.length > MAX_TITLE_LENGTH || purpose.length > MAX_PURPOSE_LENGTH) {\r\n throw new Error(`Keep the title within ${MAX_TITLE_LENGTH} characters and the purpose within ${MAX_PURPOSE_LENGTH}.`)\r\n }\r\n return { title, purpose }\r\n}\r\n\r\nfunction clip(text: string, max: number): string {\r\n return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text\r\n}\r\n\r\n// Название записи, которую никто не назвал: из настроек запуска и имени скрипта, который открыл браузер\r\nexport function automaticCaption(env: Record<string, string | undefined> = process.env, script: string | undefined = process.argv[1]): RecordingCaption {\r\n const from = script ? `, opened by ${basename(script)}` : ''\r\n return {\r\n title: clip(env[DEFAULT_TITLE_ENV]?.trim() || 'Browser session', MAX_TITLE_LENGTH),\r\n purpose: clip(`${env[DEFAULT_PURPOSE_ENV]?.trim() || 'Recorded automatically while the agent used a browser'}${from}`, MAX_PURPOSE_LENGTH),\r\n }\r\n}\r\n\r\n// Сборка rrweb без модулей: UMD кладёт её в локальный module.exports, страница своего rrweb не видит и не теряет\r\nfunction rrwebSource(): string {\r\n const entry = createRequire(import.meta.url).resolve('rrweb')\r\n return readFileSync(join(dirname(entry), 'rrweb.umd.min.cjs'), 'utf8')\r\n}\r\n\r\n// Скрипт для каждой страницы: пишет только верхний документ, iframe того же сайта rrweb снимает сам.\r\n// События уходят в программу пачками через exposeBinding, пароли скрыты, как в rrweb по умолчанию\r\nexport function pageScript(rrweb: string): string {\r\n return `(() => {\r\nif (window.top !== window || window.__tamsenseRecording) return;\r\nwindow.__tamsenseRecording = true;\r\nconst rrweb = (() => { const module = { exports: {} }; const exports = module.exports; ${rrweb}\r\n; return module.exports; })();\r\nlet buffer = [];\r\nlet timer = null;\r\nconst send = (extra) => {\r\n clearTimeout(timer);\r\n timer = null;\r\n if (!buffer.length && !extra) return Promise.resolve();\r\n const events = buffer;\r\n buffer = [];\r\n return window.${BINDING}(JSON.stringify({ events, ...extra })).catch(() => {});\r\n};\r\nrrweb.record({\r\n emit(event) {\r\n buffer.push(event);\r\n if (!timer) timer = setTimeout(send, ${FLUSH_MS});\r\n },\r\n maskInputOptions: { password: true },\r\n});\r\nconst crossOrigin = (frame) => { try { return new URL(frame.src, location.href).origin !== location.origin; } catch { return false; } };\r\nconst checkGaps = () => {\r\n const gaps = [];\r\n if (document.querySelector('canvas')) gaps.push('canvas');\r\n if (document.querySelector('video')) gaps.push('video');\r\n if ([...document.querySelectorAll('iframe')].some(crossOrigin)) gaps.push('cross-origin iframe');\r\n if (gaps.length) send({ gaps });\r\n};\r\nwindow.__tamsenseFlush = () => send();\r\nwindow.__tamsenseMark = (label) => { rrweb.record.addCustomEvent('${MARK_TAG}', { label }); return send(); };\r\naddEventListener('pagehide', () => { send(); });\r\naddEventListener('load', () => setTimeout(checkGaps, ${GAP_CHECK_MS}));\r\n})();`\r\n}\r\n\r\nfunction script(): string {\r\n const current = state()\r\n current.script ??= pageScript(rrwebSource())\r\n return current.script\r\n}\r\n\r\nclass Session {\r\n readonly file: string\r\n readonly metaFile: string\r\n readonly meta: SessionMeta\r\n private bytes = 0\r\n\r\n constructor(dir: string, recording: ContextRecording, url: string) {\r\n const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`\r\n this.file = join(dir, `${id}${REPLAY_SUFFIX}`)\r\n this.metaFile = join(dir, `${id}${META_SUFFIX}`)\r\n this.meta = { version: 1, id, ...recording.caption, startedAt: Date.now(), endedAt: null, url, marks: [], video: null, gaps: [], blockedTrackerRequests: 0, blockedExternalWrites: 0, truncated: false, automatic: recording.automatic }\r\n writeFileSync(this.file, '')\r\n this.save()\r\n }\r\n\r\n save(): void {\r\n writeFileSync(this.metaFile, `${JSON.stringify(this.meta, null, 2)}\\n`)\r\n }\r\n\r\n receive(payload: string): void {\r\n let batch: { events?: unknown[], gaps?: string[] }\r\n try {\r\n batch = JSON.parse(payload) as typeof batch\r\n }\r\n catch {\r\n return\r\n }\r\n if (batch.gaps?.length) {\r\n this.meta.gaps = [...new Set([...this.meta.gaps, ...batch.gaps])]\r\n this.save()\r\n }\r\n const page = (batch.events as Array<{ type?: unknown, data?: { href?: unknown } }> | undefined)?.find(event => event.type === RRWEB_META && typeof event.data?.href === 'string' && event.data.href !== 'about:blank')\r\n if (page && this.meta.url === 'about:blank') {\r\n this.meta.url = String(page.data!.href)\r\n this.save()\r\n }\r\n const lines = (batch.events ?? []).map(event => JSON.stringify(event)).join('\\n')\r\n if (!lines || this.meta.truncated) {\r\n return\r\n }\r\n if (this.bytes + lines.length > MAX_SESSION_BYTES) {\r\n this.meta.truncated = true\r\n this.save()\r\n return\r\n }\r\n this.bytes += lines.length + 1\r\n appendFileSync(this.file, `${lines}\\n`)\r\n }\r\n\r\n end(): void {\r\n if (this.meta.endedAt === null) {\r\n this.meta.endedAt = Date.now()\r\n this.save()\r\n }\r\n }\r\n\r\n summary(): RecordingSummary {\r\n const { title, purpose, video, marks, gaps, blockedTrackerRequests, blockedExternalWrites } = this.meta\r\n return { title, purpose, replay: this.file, video, marks: marks.length, gaps, blockedTrackerRequests, blockedExternalWrites }\r\n }\r\n}\r\n\r\nfunction countBlocked(route: PlaywrightRoute, counter: 'blockedTrackerRequests' | 'blockedExternalWrites'): void {\r\n try {\r\n const session = state().pages.get(route.request().frame().page())\r\n if (session) {\r\n session.meta[counter] += 1\r\n session.save()\r\n }\r\n }\r\n catch {}\r\n}\r\n\r\nasync function startSession(recording: ContextRecording, page: PlaywrightPage, injectNow: boolean): Promise<void> {\r\n const current = state()\r\n if (current.pages.has(page)) {\r\n return\r\n }\r\n const session = new Session(recordingsDir(), recording, page.url())\r\n current.pages.set(page, session)\r\n recording.sessions.push(session)\r\n page.on('close', () => session.end())\r\n const video = page.video()\r\n if (video) {\r\n session.meta.video = await video.path().catch(() => null)\r\n session.save()\r\n }\r\n // страница уже была открыта до записи: скрипт для будущих страниц её не коснётся\r\n if (injectNow) {\r\n await page.evaluate(script()).catch(() => {})\r\n }\r\n}\r\n\r\nasync function flush(context: PlaywrightContext): Promise<void> {\r\n await Promise.all(context.pages().filter(page => !page.isClosed()).map(page =>\r\n page.evaluate(() => (window as unknown as { __tamsenseFlush?: () => Promise<void> }).__tamsenseFlush?.(), undefined).catch(() => {}),\r\n ))\r\n}\r\n\r\n// Записывает контекст Playwright; второй вызов для того же контекста только меняет название и назначение записи.\r\n// automatic: контекст записан сам, название придумала программа\r\nexport async function recordContext(context: PlaywrightContext, caption: RecordingCaption, automatic = false): Promise<void> {\r\n const checked = checkCaption(caption)\r\n const current = state()\r\n const known = current.contexts.get(context)\r\n if (known) {\r\n Object.assign(known, { caption: checked, automatic })\r\n for (const session of known.sessions) {\r\n Object.assign(session.meta, checked, { automatic })\r\n session.save()\r\n }\r\n return\r\n }\r\n mkdirSync(recordingsDir(), { recursive: true })\r\n const recording: ContextRecording = { caption: checked, automatic, sessions: [] }\r\n current.contexts.set(context, recording)\r\n const origins = trackerOrigins()\r\n // маршруты Playwright идут от последнего к первому: трекер отвечает раньше, запись наружу отменяется после\r\n await context.route(url => !isLocalHost(url.hostname), async (route) => {\r\n const request = route.request()\r\n if (!isExternalWrite(request.method(), new URL(request.url()))) {\r\n // страница могла закрыться, пока запрос шёл\r\n await route.continue().catch(() => {})\r\n return\r\n }\r\n countBlocked(route, 'blockedExternalWrites')\r\n await route.abort('blockedbyclient').catch(() => {})\r\n })\r\n await context.route(url => isTrackerIngest(url, origins), async (route) => {\r\n countBlocked(route, 'blockedTrackerRequests')\r\n await route.fulfill({ status: 200, contentType: 'application/json', body: '{\"ok\":true}' })\r\n })\r\n await context.exposeBinding(BINDING, ({ page }, payload) => current.pages.get(page)?.receive(payload))\r\n await context.addInitScript({ content: script() })\r\n context.on('page', page => void startSession(recording, page, false))\r\n context.on('close', () => recording.sessions.forEach(session => session.end()))\r\n for (const page of context.pages()) {\r\n await startSession(recording, page, true)\r\n }\r\n const close = context.close.bind(context)\r\n context.close = async (options?: unknown) => {\r\n await flush(context)\r\n return close(options)\r\n }\r\n}\r\n\r\n// Новый записанный контекст: название и назначение обязательны, video: true добавляет видео Playwright\r\nexport async function newRecordedContext(browser: PlaywrightBrowser, options: RecordedContextOptions): Promise<PlaywrightContext> {\r\n const caption = checkCaption(options)\r\n const extra = options.video ? { recordVideo: { dir: join(recordingsDir(), 'video') } } : {}\r\n const context = await browser.newContext({ ...options.context, ...extra })\r\n await recordContext(context, caption)\r\n return context\r\n}\r\n\r\n// Отметка шага: видна на шкале плеера и в списке отметок, клик по ней перематывает запись к этому месту\r\nexport async function mark(page: PlaywrightPage, label: string): Promise<void> {\r\n const text = clip(String(label ?? '').trim(), MAX_LABEL_LENGTH)\r\n const session = state().pages.get(page)\r\n if (!text || !session) {\r\n warn(text ? 'mark() was called for a page that is not recorded' : 'mark() needs a label')\r\n return\r\n }\r\n session.meta.marks.push({ ts: Date.now(), label: text })\r\n session.save()\r\n await page.evaluate(value => (window as unknown as { __tamsenseMark?: (label: string) => Promise<void> }).__tamsenseMark?.(value), text).catch(() => {})\r\n}\r\n\r\n// Закрывает записанный контекст и говорит, что записано; подсказывает видео, если записи не хватит\r\nexport async function closeRecorded(context: PlaywrightContext): Promise<RecordingSummary[]> {\r\n await context.close()\r\n const sessions = state().contexts.get(context)?.sessions ?? []\r\n const summaries = sessions.map(session => session.summary())\r\n for (const summary of summaries) {\r\n if (summary.gaps.length > 0 && !summary.video) {\r\n warn(`\"${summary.title}\" has ${summary.gaps.join(', ')}: the replay may not show it. Record it again with video: true.`)\r\n }\r\n }\r\n return summaries\r\n}\r\n\r\nfunction instrument(exports: unknown): void {\r\n const chromium = (exports as { chromium?: { launch?: unknown, _instrumentation?: Instrumentation } } | null)?.chromium\r\n const instrumentation = (exports as { _instrumentation?: Instrumentation } | null)?._instrumentation ?? chromium?._instrumentation\r\n const current = state()\r\n if (typeof chromium?.launch !== 'function' || typeof instrumentation?.addListener !== 'function' || current.instrumented.has(instrumentation)) {\r\n return\r\n }\r\n current.instrumented.add(instrumentation)\r\n instrumentation.addListener({\r\n runAfterCreateBrowserContext: async (context) => {\r\n try {\r\n if (!current.contexts.has(context)) {\r\n await recordContext(context, automaticCaption(), true)\r\n }\r\n }\r\n catch (error) {\r\n warn(`could not record this browser: ${error instanceof Error ? error.message : String(error)}`)\r\n }\r\n },\r\n })\r\n}\r\n\r\n// Сам цепляется к Playwright, как только его загрузят: агент не может забыть включить запись\r\nexport function installAutoRecording(): void {\r\n const current = state()\r\n if (current.hooked) {\r\n return\r\n }\r\n current.hooked = true\r\n const loader = Module as unknown as { _load: (request: string, parent: unknown, isMain: boolean) => unknown }\r\n const load = loader._load\r\n loader._load = function (this: unknown, ...args: [string, unknown, boolean]) {\r\n const exports = load.apply(this, args)\r\n try {\r\n instrument(exports)\r\n }\r\n catch {}\r\n return exports\r\n }\r\n}\r\n\r\nif (process.env[RECORDINGS_DIR_ENV]) {\r\n installAutoRecording()\r\n}\r\n","// Shim globals in cjs bundle\n// There's a weird bug that esbuild will always inject importMetaUrl\n// if we export it as `const importMetaUrl = ... __filename ...`\n// But using a function will not cause this issue\n\nconst getImportMetaUrl = () => \n typeof document === \"undefined\" \n ? new URL(`file:${__filename}`).href \n : (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT') \n ? document.currentScript.src \n : new URL(\"main.js\", document.baseURI).href;\n\nexport const importMetaUrl = /* @__PURE__ */ getImportMetaUrl()\n"],"mappings":"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","names":["process","script","exports","Module"]}
|
|
@@ -44,7 +44,7 @@ Blocker: broken, clipped, unreachable, unreadable, missing states for fetched da
|
|
|
44
44
|
## What not to do
|
|
45
45
|
|
|
46
46
|
- Do not edit, create or delete files in the repository. Scripts, screenshots and sign-in state live only in the temp directory.
|
|
47
|
-
- Do not
|
|
47
|
+
- Do not add packages, download browsers, push, commit, or open pull requests. Installing the project's own dependencies from its lockfile is fine.
|
|
48
48
|
- Do not use external services or production URLs; only the local app.
|
|
49
49
|
- Do not review screens the change did not touch, except one line under "Outside scope" if something is badly broken.
|
|
50
50
|
- Do not report personal preference as a finding, and do not invent problems to fill the list. "No supported findings" is a valid result.
|
|
@@ -15,6 +15,7 @@ The caller should give you the task or acceptance criteria, the repository folde
|
|
|
15
15
|
1. Load the `ui-testing` skill with the Skill tool and follow it. Load `accessibility` too when the change adds or alters interactive controls, forms, dialogs or colours. The skills' reference files (the headless check script, Playwright patterns, the keyboard pass) are in the folders the Skill tool reports.
|
|
16
16
|
2. Write the scenario list first: the changed flow, its loading, empty and error states, a 390 px width if layout changed, keyboard use of new controls.
|
|
17
17
|
3. Find the project's E2E setup and how the app starts (scripts, CI workflow, `.env.example`). Check required environment variables by name only.
|
|
18
|
+
Start only a local copy from the task folder, as the `ui-testing` skill's `references/local-stand.md` says; if Docker is not running, there is no local setup or the settings point outside this computer, start nothing and return `UI check: could not run` with the exact reason and what a person should do.
|
|
18
19
|
4. Run the related E2E specs headless if a suite exists; check what it does not cover with the headless check script written to a new folder in the OS temp directory.
|
|
19
20
|
5. Every browser you open is recorded for the task. In your own scripts, open contexts through the recorder in `TAMSENSE_RECORDER` with a title named after the goal and attempt ("Feature testing", "Second attempt at feature testing") and a one-line purpose, call `mark` at each step of a scenario, and add `video: true` for canvas, maps, video players or iframes from another site, or when the caller asks for a video (the `ui-testing` skill's `references/recording.md`).
|
|
20
21
|
6. Open every screenshot with Read and describe what it shows.
|
|
@@ -43,7 +44,7 @@ Keep it short: quote only the error lines that matter, never whole logs, and nev
|
|
|
43
44
|
|
|
44
45
|
- Do not edit, create or delete files in the repository, including tests, snapshots and config. Scratch scripts, screenshots, traces and auth state go to the temp folder.
|
|
45
46
|
- Do not open visible windows: no `--headed`, `--ui`, `--debug`, `show-report`, `show-trace`, `cypress open`. Run Playwright with `--reporter=line` and `PW_TEST_HTML_REPORT_OPEN=never`, and start dev servers with `BROWSER=none`.
|
|
46
|
-
- Do not
|
|
47
|
-
- Do not point the check at staging or production, and do not
|
|
47
|
+
- Do not add packages or download browsers; installing the project's own dependencies from its lockfile is fine. If no headless browser is available, say so and stop.
|
|
48
|
+
- Do not point the check at staging or production, and do not start the app when its settings point at a shared backend or database.
|
|
48
49
|
- Do not skip, `fixme` or loosen failing tests, and do not report a pass for anything you did not see. If the app cannot start, report the exact reason (missing variables, services, build error) with the last relevant log lines.
|
|
49
50
|
- Do not push, commit or comment anywhere.
|
|
@@ -12,12 +12,12 @@ The project's own setup wins: its E2E framework, config, fixtures, page objects,
|
|
|
12
12
|
## Hard rules
|
|
13
13
|
|
|
14
14
|
1. **Headless only.** Never open a window: no `--headed`, `--ui`, `--debug`, `show-report`, `show-trace`, `cypress open`, `PWDEBUG`. Playwright's HTML reporter opens a browser on failure when not in CI, so run with `--reporter=line` and `PW_TEST_HTML_REPORT_OPEN=never`. Set `BROWSER=none` when starting dev servers so they do not open one either.
|
|
15
|
-
2. **No new dependencies.** Do not add packages to the repository and do not download browsers (`playwright install`, `npx some-tool@latest`) just to check.
|
|
16
|
-
3. **
|
|
15
|
+
2. **No new dependencies.** Do not add packages to the repository and do not download browsers (`playwright install`, `npx some-tool@latest`) just to check. Installing the project's own dependencies from its lockfile (`pnpm install --frozen-lockfile`, `npm ci`) is fine: a fresh checkout often has none. If the project has no Playwright but the `TAMSENSE_PLAYWRIGHT` environment variable is set, the TAMsense runner has already installed `playwright-core` and a headless browser outside the project, and the headless check script uses them. If nothing is, check from the code and say the browser check could not run.
|
|
16
|
+
3. **A local copy from the task folder, with the project's own start command, on a free port.** Start the app with its dev or preview script, never a hand-made server that skips its config. Find its local setup yourself, use only local settings from this checkout, check that every address points at this computer, and run its containers as your own compose project: see [references/local-stand.md](references/local-stand.md). Prefer the headless check script, which starts the server and stops its whole process tree for you. Stop it at the end, including child processes, and confirm the port is free again.
|
|
17
17
|
8. **Stop only what you started, by process id.** This is someone's own computer and their other programs may be running. Never kill processes by name or pattern: no `taskkill /IM`, `pkill`, `killall`, `Stop-Process -Name`. If you start a server yourself, record its PID and stop that process tree (`taskkill /PID <pid> /T /F` on Windows, `kill -- -<pgid>` elsewhere). If a process you started cannot be stopped that way, say so in the report and leave the rest alone.
|
|
18
18
|
4. **Outputs outside the repository.** Screenshots, traces, reports, scratch scripts and auth state go to a new folder in the OS temp directory. `git status` must show nothing new from you when you finish.
|
|
19
19
|
5. **Never fake it.** If the app cannot start (missing environment variables, secrets, a database or service you may not use), report exactly what is missing. Do not mock the whole backend and call the result a pass, and do not invent screenshots or results.
|
|
20
|
-
6. **Do not write to shared systems.** If the
|
|
20
|
+
6. **Do not write to shared systems.** If the settings point at a shared, staging or production backend or database, do not start the app: the check could not run (local-stand.md, rule 7). Recorded browsers also abort requests that change data on other computers.
|
|
21
21
|
7. **Never hide failures.** Do not skip, `fixme`, delete or loosen a failing test to get a green run. In a check step, a failing test is a finding.
|
|
22
22
|
9. **Every browser is recorded for the task.** In a TAMsense run each Playwright context becomes a replay people watch on the task, and the tested site's TAMsense tracker is blocked. Open contexts through the recorder with a title and a purpose, mark the meaningful steps, and add a video for canvas, maps, video players or iframes from another site: see [references/recording.md](references/recording.md).
|
|
23
23
|
|
|
@@ -53,7 +53,7 @@ In a code step, add or update tests for the changed flow following the suite's s
|
|
|
53
53
|
|
|
54
54
|
Use the headless check in [references/headless-check.md](references/headless-check.md): a Node.js script you write to a temp folder that resolves Playwright from the project, starts the app with the project's command on a free port, visits the pages at 1280 and 390 px, runs your steps with marks, records each page for the task, saves screenshots and a JSON summary (console errors, failed requests, horizontal overflow, optional axe scan), and stops the app.
|
|
55
55
|
|
|
56
|
-
If
|
|
56
|
+
If the project has no Playwright among its dependencies and no other headless browser tool is available, do not add one. Check the change from the code and say so.
|
|
57
57
|
|
|
58
58
|
### 4. Look at the evidence
|
|
59
59
|
|
|
@@ -80,7 +80,7 @@ Not checked: <and why: missing env vars X, Y; no browser available; flow needs a
|
|
|
80
80
|
Assumptions: <test account used, data you created locally, version>
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
In a test step, base the verdict on the scenarios: a broken acceptance criterion, an error from the changed code or a layout broken on narrow screens fails it. If the browser check could not run, do not report it as passed; judge from the other evidence (tests, code) and state clearly that the UI was not seen. If the change itself stops the app from starting or building, that is a failure.
|
|
83
|
+
In a test step, base the verdict on the scenarios: a broken acceptance criterion, an error from the changed code or a layout broken on narrow screens fails it. If the local copy could not start because of this computer (Docker not running, no local setup, settings that point outside), finish as [references/local-stand.md](references/local-stand.md) rule 7 says: `couldNotRun`, no handoff, the task waits for a person. If the browser check could not run for another reason, do not report it as passed; judge from the other evidence (tests, code) and state clearly that the UI was not seen. If the change itself stops the app from starting or building, that is a failure.
|
|
84
84
|
|
|
85
85
|
## Common mistakes
|
|
86
86
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Starting a local copy of the app
|
|
2
|
+
|
|
3
|
+
Read this before you start the app for a check. In a TAMsense run the task folder is a fresh checkout: no dependencies, no `.env`, nothing running. You set up a local copy from this checkout yourself, every time, and you take it down at the end. The person's own setup, their databases and every shared server stay untouched.
|
|
4
|
+
|
|
5
|
+
## The rules
|
|
6
|
+
|
|
7
|
+
1. **This task folder only.** Everything you start comes from the checkout in the task folder. Never read or copy `.env` files, keys or settings from elsewhere on this computer, including the person's own checkout of the same project.
|
|
8
|
+
2. **Find the local setup yourself.** Look in the README, compose files named local or dev (`docker-compose.local.yml`, `infra/compose/local.compose.yml`, `compose.dev.yaml`), `.env.example` and other env files meant for a local run (`.env.local.example`, `.env.development`), and the package scripts (`dev`, `db:migrate`, `db:seed`). The CI workflow often shows the exact services and commands.
|
|
9
|
+
3. **Check every address before you start.** In the settings you use, databases, caches, queues and APIs must point at `localhost`, `127.0.0.1` or containers you start. A staging, shared dev or production address, or a cloud host you do not start, means stop (rule 7). Keys of outside services (payments, mail, analytics, storage, AI APIs) stay empty; never fill them with real ones. Check variables by name and address only; never print secrets.
|
|
10
|
+
4. **Your own compose project on free ports.** Start containers as a separate compose project named `tamsense-task-<task id>`, so they never mix with the person's containers. Map them to free host ports with an override file in a temp folder, not by editing the project's files. Find a free port by asking the system (`node -e "const s=require('net').createServer().listen(0,()=>{console.log(s.address().port);s.close()})"`).
|
|
11
|
+
5. **Never touch containers you did not start.** Do not stop, restart, remove or change other containers, volumes or networks, even when a port is taken: pick another port.
|
|
12
|
+
6. **Clean up.** At the end run `docker compose -p tamsense-task-<task id> down -v` with the same files, stop the app by its process id, and delete the temp folder.
|
|
13
|
+
7. **When it cannot run, stop.** If Docker is not running, the project has no local setup, or the settings point outside this computer, do not start anything and do not look for a workaround. In a check step, call `submit_verdict` with `passed: false` and `couldNotRun: true`, write the exact reason and what a person should do in the report ("Docker is not running: start Docker Desktop and run this step again"), and do not hand off. The task then waits on this step for a person. In a code step, say so in your report.
|
|
14
|
+
|
|
15
|
+
The browser adds a guard of its own: requests other than GET, HEAD and OPTIONS to an address outside this computer are aborted (see [recording.md](recording.md)). It does not replace rule 3: the app's server talks to its database and services directly.
|
|
16
|
+
|
|
17
|
+
## Example
|
|
18
|
+
|
|
19
|
+
Task 40, a project with `infra/compose/local.compose.yml` (postgres and redis), `.env.example` pointing at `localhost:5432` and `localhost:6379`, and a `dev` script.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
docker info --format '{{.ServerVersion}}' # an error here means Docker is not running: stop (rule 7)
|
|
23
|
+
TMP=$(node -p "require('fs').mkdtempSync(require('path').join(require('os').tmpdir(), 'tamsense-task-40-'))")
|
|
24
|
+
|
|
25
|
+
# free host ports for the two services, found as in rule 4
|
|
26
|
+
cat > "$TMP/ports.override.yml" <<'EOF'
|
|
27
|
+
services:
|
|
28
|
+
postgres:
|
|
29
|
+
ports: !override
|
|
30
|
+
- "55432:5432"
|
|
31
|
+
redis:
|
|
32
|
+
ports: !override
|
|
33
|
+
- "56379:6379"
|
|
34
|
+
EOF
|
|
35
|
+
|
|
36
|
+
docker compose -p tamsense-task-40 -f infra/compose/local.compose.yml -f "$TMP/ports.override.yml" up -d postgres redis
|
|
37
|
+
|
|
38
|
+
pnpm install --frozen-lockfile
|
|
39
|
+
# settings from .env.example, with the ports changed to the free ones, passed as variables, not written into the repository
|
|
40
|
+
export DATABASE_URL=postgresql://postgres:postgres@localhost:55432/app REDIS_URL=redis://localhost:56379
|
|
41
|
+
pnpm db:migrate # migrations go to the local database only
|
|
42
|
+
BROWSER=none pnpm dev --port 53100 # the project's own dev script on a free port; note its process id
|
|
43
|
+
|
|
44
|
+
# ... the headless check against http://localhost:53100 ...
|
|
45
|
+
|
|
46
|
+
# stop the dev server by its process id, then
|
|
47
|
+
docker compose -p tamsense-task-40 -f infra/compose/local.compose.yml -f "$TMP/ports.override.yml" down -v
|
|
48
|
+
rm -rf "$TMP"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Before `db:migrate`, look at the address once more: `DATABASE_URL` must name `localhost` and the port of your container. `!override` (Docker Compose 2.24 or newer) replaces the project's own port list instead of adding to it, so nothing binds the ports the person's own containers may use.
|
|
@@ -6,6 +6,7 @@ Read this before any script that opens a browser during a TAMsense run. People o
|
|
|
6
6
|
|
|
7
7
|
- When the `TAMSENSE_RECORDER` environment variable is set, every Node process you start loads the TAMsense recorder, and every Playwright browser context is recorded, also the ones opened by the project's own E2E tests. You cannot switch this off, and you do not need to.
|
|
8
8
|
- Requests from the tested site to the TAMsense tracker (`/api/events` with a `?key=` or sent to the TAMsense server) are answered by the recorder and never leave the browser, so your runs do not reach the product's analytics.
|
|
9
|
+
- Requests other than GET, HEAD and OPTIONS to an address outside this computer (anything but `localhost`, `*.localhost` and loopback addresses) are aborted, so a test run never writes to an outside service. The summary from `closeRecorded` counts them in `blockedExternalWrites`. If a flow you check needs such a write, it was set up against an outside service: say so instead of working around it.
|
|
9
10
|
- Password fields are masked in the replay.
|
|
10
11
|
- Recordings you did not attach yourself are attached when the run ends. Unnamed ones get the step name as their title, so name yours.
|
|
11
12
|
|