From eef4f4bcea74b1cb5ed2b60fc21befc2aee51170 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sun, 10 May 2026 17:58:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(web-test):=20T4.5=20=E2=80=94=20=D0=BC?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=D1=82=D0=B8-=D0=BA=D0=BE=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D0=BD=D0=B0=D1=8F=20=D0=B7=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D1=8C=20=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit browser.mjs v1.11: recorder стал глобальным (не per-slot) — один ffmpeg, один mp4 на тест с любым числом переключений контекста. Frame state (lastFrameBuf/lastFrameTime/handler) переехал в поля recorder. Добавлен recorder._attachPage(targetPage) — стопает старый CDP screencast, заводит новый на нужной странице, route'ит фреймы в тот же ffmpeg pipe. setActiveContext: при активной записи делает _flushFrames (замораживает хвост уходящего окна), затем _attachPage(page) после _activateSlot. Видео получается непрерывным с плавным сюжетом — пока активен a, видно a; пока активен b, видно b. _saveActiveSlot/_activateSlot больше не трогают recorder/lastCaptions/ lastRecordingDuration — recorder следует за активной страницей через _attachPage, не через slot mirror. disconnect: убрал leftover из T4.1, который пытался итерировать slot.recorder. Live: 15-multi-context-handover с --record → 17.84s mp4, 446 кадров @ 25fps, извлечённые кадры показывают переключение между окнами a (1920x1042) и b (982x546). Полный регресс 11/12 (04-selectvalue — pre-existing flake). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 145 +++++++++++--------- 1 file changed, 78 insertions(+), 67 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index c1edeca6..3bdae67c 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -1,4 +1,4 @@ -// web-test browser v1.10 — Playwright browser management for 1C web client +// web-test browser v1.11 — Playwright browser management for 1C web client // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * Playwright browser management for 1C web client. @@ -169,18 +169,12 @@ export async function connect(url, { extensionPath } = {}) { * Sends POST /e1cib/logout to release the license before closing. */ export async function disconnect() { - // Multi-context path: stop recordings + logout each slot before closing browser + // Multi-context path: stop recording + logout each slot before closing browser if (contexts.size > 0) { - // Save current active first so iteration is consistent _saveActiveSlot(); - for (const [name, slot] of contexts.entries()) { - // Stop recording per slot if any - if (slot.recorder) { - _activateSlot(name); - try { await stopRecording(); } catch {} - // re-save in case stopRecording mutated state - _saveActiveSlot(); - } + // Recorder is global — one stop covers all contexts + if (recorder) { + try { await stopRecording(); } catch {} } for (const [, slot] of contexts.entries()) { if (slot.page && !slot.page.isClosed() && slot.seanceId && slot.sessionPrefix) { @@ -277,10 +271,10 @@ function _saveActiveSlot() { slot.page = page; slot.sessionPrefix = sessionPrefix; slot.seanceId = seanceId; - slot.recorder = recorder; - slot.lastCaptions = lastCaptions; - slot.lastRecordingDuration = lastRecordingDuration; slot.highlightMode = highlightMode; + // Note: `recorder`, `lastCaptions`, `lastRecordingDuration` are intentionally NOT + // mirrored per-slot. A multi-context recording produces one continuous output file — + // the recorder follows the active page via recorder._attachPage(), not per-slot state. } /** Load a slot's state into module-level vars and mark it active. */ @@ -290,9 +284,6 @@ function _activateSlot(name) { page = slot.page; sessionPrefix = slot.sessionPrefix; seanceId = slot.seanceId; - recorder = slot.recorder; - lastCaptions = slot.lastCaptions || []; - lastRecordingDuration = slot.lastRecordingDuration; highlightMode = slot.highlightMode || false; activeContextName = name; } @@ -382,8 +373,16 @@ export async function createContext(name, url, { extensionPath } = {}) { export async function setActiveContext(name) { if (activeContextName === name) return; if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`); + // If a recording is active, flush the outgoing page's last frame so the gap is filled + // up to the moment of the switch (avoids a "jump" in video time). + if (recorder && recorder._flushFrames) recorder._flushFrames(); _saveActiveSlot(); _activateSlot(name); + // If the recording is still alive (it lives across slots — we keep the same ffmpeg/output), + // re-attach its screencast to the newly active page. + if (recorder && recorder._attachPage) { + await recorder._attachPage(page); + } } export function listContexts() { @@ -5031,10 +5030,7 @@ export async function startRecording(outputPath, opts = {}) { const resolvedPath = resolveProjectPath(outputPath); mkdirSync(dirname(resolvedPath), { recursive: true }); - // Create CDP session for screencast - const cdp = await page.context().newCDPSession(page); - - // Spawn ffmpeg process + // Spawn ffmpeg process — single output file across context switches const ffmpeg = spawn(ffmpegPath, [ '-y', // overwrite output '-f', 'image2pipe', // input: piped images @@ -5050,71 +5046,86 @@ export async function startRecording(outputPath, opts = {}) { resolvedPath ], { stdio: ['pipe', 'ignore', 'pipe'] }); - let ffmpegError = ''; - ffmpeg.stderr.on('data', d => { ffmpegError += d.toString(); }); - ffmpeg.on('error', err => { ffmpegError += err.message; }); + ffmpeg.on('error', err => { if (recorder) recorder.ffmpegError += err.message; }); - // Listen for screencast frames and pipe to ffmpeg - // CDP sends frames only on screen changes, so we duplicate frames - // to fill gaps and maintain real-time playback speed const frameDuration = 1000 / fps; - let lastFrameTime = null; - let lastFrameBuf = null; + const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait - cdp.on('Page.screencastFrame', async ({ data, sessionId }) => { + // Frame handler shared across CDP sessions (lives in recorder, not closure): + // when the active context switches, we attach a new CDP session and route its + // frames to the same ffmpeg pipe — preserving a single continuous timeline. + const frameHandler = async ({ data, sessionId }, cdp) => { + if (!recorder) return; const buf = Buffer.from(data, 'base64'); const now = Date.now(); - if (!ffmpeg.stdin.destroyed) { let framesWritten = 0; - if (lastFrameTime && lastFrameBuf) { - // Fill the gap with duplicates of the previous frame - const gap = now - lastFrameTime; + if (recorder.lastFrameTime && recorder.lastFrameBuf) { + const gap = now - recorder.lastFrameTime; const dupes = Math.round(gap / frameDuration) - 1; for (let i = 0; i < dupes && i < fps * 30; i++) { - ffmpeg.stdin.write(lastFrameBuf); + ffmpeg.stdin.write(recorder.lastFrameBuf); framesWritten++; } } ffmpeg.stdin.write(buf); framesWritten++; - // Track actual video timeline position (accounts for frame duplication) - if (recorder) recorder.videoTimeMs += framesWritten * frameDuration; + recorder.videoTimeMs += framesWritten * frameDuration; } - - lastFrameTime = now; - lastFrameBuf = buf; + recorder.lastFrameTime = now; + recorder.lastFrameBuf = buf; try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {} - }); - - // Start the screencast - await cdp.send('Page.startScreencast', { - format: 'jpeg', - quality, - everyNthFrame: 1 - }); - - // Expose a frame-writing helper on the recorder object. - // During static periods (e.g. smart TTS pauses), CDP may not send screencast - // frames. Call _flushFrames() to fill the gap with duplicates of the last frame, - // keeping video timeline in sync with wall-clock time. - const _flushFrames = () => { - if (!lastFrameBuf || !lastFrameTime || ffmpeg.stdin.destroyed) return; - const now = Date.now(); - const gap = now - lastFrameTime; - const dupes = Math.round(gap / frameDuration); - for (let i = 0; i < dupes; i++) { - ffmpeg.stdin.write(lastFrameBuf); - if (recorder) recorder.videoTimeMs += frameDuration; - } - if (dupes > 0) lastFrameTime = now; }; - const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait - recorder = { cdp, ffmpeg, startTime: Date.now(), outputPath: resolvedPath, ffmpegError: '', captions: [], videoTimeMs: 0, _flushFrames, speechRate }; - // Redirect stderr accumulation to the recorder object - ffmpeg.stderr.removeAllListeners('data'); + // Duplicate the last frame to fill wall-clock gaps (static periods, context switches). + const _flushFrames = () => { + if (!recorder || !recorder.lastFrameBuf || !recorder.lastFrameTime || ffmpeg.stdin.destroyed) return; + const now = Date.now(); + const gap = now - recorder.lastFrameTime; + const dupes = Math.round(gap / frameDuration); + for (let i = 0; i < dupes; i++) { + ffmpeg.stdin.write(recorder.lastFrameBuf); + recorder.videoTimeMs += frameDuration; + } + if (dupes > 0) recorder.lastFrameTime = now; + }; + + // Attach screencast to a specific page. Stops the old CDP first (if any). + // Called by startRecording for the initial page, and by setActiveContext when + // the active context changes mid-recording. + const _attachPage = async (targetPage) => { + if (recorder.cdp) { + _flushFrames(); // freeze the last frame of the outgoing page up to "now" + try { await recorder.cdp.send('Page.stopScreencast'); } catch {} + try { await recorder.cdp.detach(); } catch {} + recorder.cdp = null; + } + const cdp = await targetPage.context().newCDPSession(targetPage); + cdp.on('Page.screencastFrame', (ev) => frameHandler(ev, cdp)); + await cdp.send('Page.startScreencast', { format: 'jpeg', quality, everyNthFrame: 1 }); + recorder.cdp = cdp; + recorder.activePage = targetPage; + }; + + recorder = { + cdp: null, + activePage: null, + ffmpeg, + startTime: Date.now(), + outputPath: resolvedPath, + ffmpegError: '', + captions: [], + videoTimeMs: 0, + frameDuration, + lastFrameTime: null, + lastFrameBuf: null, + _flushFrames, + _attachPage, + speechRate, + }; ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); }); + + await _attachPage(page); } /**