@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/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) => isTrackerIngest(url, origins), async (route) => {
280
- try {
281
- const session = current.pages.get(route.request().frame().page());
282
- if (session) {
283
- session.meta.blockedTrackerRequests += 1;
284
- session.save();
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,
@@ -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 install packages or browsers, push, commit, or open pull requests.
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 install packages or download browsers. If no headless browser is available, say so and stop.
47
- - Do not point the check at staging or production, and do not submit forms or trigger writes when the local app talks to a shared backend or database.
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. Use what is installed. 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. **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. 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.
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 local app is configured against a shared or production backend or database, look but do not submit forms or trigger writes.
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 Playwright is not installed in the project and no other headless browser tool is available, do not install one. Check the change from the code and say so.
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
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tamsensedev/runner",
3
3
  "type": "module",
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "description": "Connects your computer to TAMsense so it can work on your tasks",
6
6
  "author": "TAMsense",
7
7
  "license": "UNLICENSED",