From 65ea06ab6e6af53c37e28f971d20976384b82bc6 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 18:08:15 +0300 Subject: [PATCH] =?UTF-8?q?refactor(web-test):=20run.mjs=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BE=20cli/=20(1?= =?UTF-8?q?258=20=E2=86=92=2065=20LOC=20entry)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Внутренности move в cli/: - util.mjs — out/die/json/readBody/readStdin/elapsed/elapsed2/slugify/formatDuration/xmlEscape/interpolate/printSteps/usage - session.mjs — SESSION_FILE, loadSession, cleanup - exec-context.mjs — buildContext, buildScopedContext, executeScript - server.mjs — handleRequest (HTTP сервер в процессе start) - commands/{start,run,exec,shot,stop,status,test}.mjs — по одной команде на файл - test-runner/assertions.mjs — createAssertions (ctx.assert API) - test-runner/severity.mjs — SEVERITY_RANK/LEVELS, buildSeverityIndex, resolveSeverity - test-runner/reporters.mjs — writeAllure, allureStep, syncAllureExtras, buildJUnit - test-runner/discover.mjs — discoverTests, resetState run.mjs остался публичным entry-point с CLI-парсингом и dispatcher'ом. Регресс tests/web-test/ зелёный (19/19, 9m 28s). --- .../web-test/scripts/cli/commands/exec.mjs | 36 + .../web-test/scripts/cli/commands/run.mjs | 22 + .../web-test/scripts/cli/commands/shot.mjs | 18 + .../web-test/scripts/cli/commands/start.mjs | 33 + .../web-test/scripts/cli/commands/status.mjs | 14 + .../web-test/scripts/cli/commands/stop.mjs | 17 + .../web-test/scripts/cli/commands/test.mjs | 433 ++++++ .../web-test/scripts/cli/exec-context.mjs | 148 ++ .../skills/web-test/scripts/cli/server.mjs | 37 + .../skills/web-test/scripts/cli/session.mjs | 20 + .../scripts/cli/test-runner/assertions.mjs | 64 + .../scripts/cli/test-runner/discover.mjs | 32 + .../scripts/cli/test-runner/reporters.mjs | 113 ++ .../scripts/cli/test-runner/severity.mjs | 66 + .claude/skills/web-test/scripts/cli/util.mjs | 111 ++ .claude/skills/web-test/scripts/run.mjs | 1323 +---------------- 16 files changed, 1229 insertions(+), 1258 deletions(-) create mode 100644 .claude/skills/web-test/scripts/cli/commands/exec.mjs create mode 100644 .claude/skills/web-test/scripts/cli/commands/run.mjs create mode 100644 .claude/skills/web-test/scripts/cli/commands/shot.mjs create mode 100644 .claude/skills/web-test/scripts/cli/commands/start.mjs create mode 100644 .claude/skills/web-test/scripts/cli/commands/status.mjs create mode 100644 .claude/skills/web-test/scripts/cli/commands/stop.mjs create mode 100644 .claude/skills/web-test/scripts/cli/commands/test.mjs create mode 100644 .claude/skills/web-test/scripts/cli/exec-context.mjs create mode 100644 .claude/skills/web-test/scripts/cli/server.mjs create mode 100644 .claude/skills/web-test/scripts/cli/session.mjs create mode 100644 .claude/skills/web-test/scripts/cli/test-runner/assertions.mjs create mode 100644 .claude/skills/web-test/scripts/cli/test-runner/discover.mjs create mode 100644 .claude/skills/web-test/scripts/cli/test-runner/reporters.mjs create mode 100644 .claude/skills/web-test/scripts/cli/test-runner/severity.mjs create mode 100644 .claude/skills/web-test/scripts/cli/util.mjs diff --git a/.claude/skills/web-test/scripts/cli/commands/exec.mjs b/.claude/skills/web-test/scripts/cli/commands/exec.mjs new file mode 100644 index 00000000..3c3186b6 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/commands/exec.mjs @@ -0,0 +1,36 @@ +// web-test cli/commands/exec v1.0 — send script to running server +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import http from 'http'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { out, die, readStdin } from '../util.mjs'; +import { loadSession } from '../session.mjs'; + +export async function cmdExec(fileOrDash, flags = {}) { + if (!fileOrDash) die('Usage: node src/run.mjs exec [--no-record]'); + + const code = fileOrDash === '-' + ? await readStdin() + : readFileSync(resolve(fileOrDash), 'utf-8'); + + const sess = loadSession(); + const headers = {}; + if (flags.noRecord) headers['x-no-record'] = '1'; + const timeoutMs = flags.execTimeoutMs ?? 30 * 60 * 1000; + const result = await new Promise((resolveP, reject) => { + const req = http.request({ + hostname: '127.0.0.1', port: sess.port, path: '/exec', + method: 'POST', timeout: timeoutMs, headers, + }, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { try { resolveP(JSON.parse(data)); } catch { reject(new Error(data)); } }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(new Error(`Exec timeout (${Math.round(timeoutMs / 60000)} min)`)); }); + req.write(code); + req.end(); + }); + out(result); + if (!result.ok) process.exit(1); +} diff --git a/.claude/skills/web-test/scripts/cli/commands/run.mjs b/.claude/skills/web-test/scripts/cli/commands/run.mjs new file mode 100644 index 00000000..cb3631cd --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/commands/run.mjs @@ -0,0 +1,22 @@ +// web-test cli/commands/run v1.0 — autonomous connect → exec → disconnect (no server) +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import * as browser from '../../browser.mjs'; +import { out, die, readStdin } from '../util.mjs'; +import { executeScript } from '../exec-context.mjs'; + +export async function cmdRun(url, fileOrDash) { + if (!url || !fileOrDash) die('Usage: node src/run.mjs run '); + + const code = fileOrDash === '-' + ? await readStdin() + : readFileSync(resolve(fileOrDash), 'utf-8'); + + await browser.connect(url); + const result = await executeScript(code); + await browser.disconnect(); + + out(result); + if (!result.ok) process.exit(1); +} diff --git a/.claude/skills/web-test/scripts/cli/commands/shot.mjs b/.claude/skills/web-test/scripts/cli/commands/shot.mjs new file mode 100644 index 00000000..71badfb2 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/commands/shot.mjs @@ -0,0 +1,18 @@ +// web-test cli/commands/shot v1.0 — take screenshot via server +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { writeFileSync } from 'fs'; +import { out, die } from '../util.mjs'; +import { loadSession } from '../session.mjs'; + +export async function cmdShot(file) { + const sess = loadSession(); + const resp = await fetch(`http://127.0.0.1:${sess.port}/shot`); + if (!resp.ok) { + const err = await resp.text(); + die(`Screenshot failed: ${err}`); + } + const buf = Buffer.from(await resp.arrayBuffer()); + const outFile = file || 'shot.png'; + writeFileSync(outFile, buf); + out({ ok: true, file: outFile }); +} diff --git a/.claude/skills/web-test/scripts/cli/commands/start.mjs b/.claude/skills/web-test/scripts/cli/commands/start.mjs new file mode 100644 index 00000000..b83d5ec2 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/commands/start.mjs @@ -0,0 +1,33 @@ +// web-test cli/commands/start v1.0 +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import http from 'http'; +import { writeFileSync } from 'fs'; +import * as browser from '../../browser.mjs'; +import { out, die } from '../util.mjs'; +import { SESSION_FILE, cleanup } from '../session.mjs'; +import { handleRequest } from '../server.mjs'; + +export async function cmdStart(url) { + if (!url) die('Usage: node src/run.mjs start '); + + const state = await browser.connect(url); + + const httpServer = http.createServer(handleRequest); + httpServer.listen(0, '127.0.0.1', () => { + const port = httpServer.address().port; + const session = { + port, + url, + pid: process.pid, + startedAt: new Date().toISOString() + }; + writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2)); + out({ ok: true, message: 'Browser ready', port, ...state }); + }); + + process.on('SIGINT', async () => { + await browser.disconnect(); + cleanup(); + process.exit(0); + }); +} diff --git a/.claude/skills/web-test/scripts/cli/commands/status.mjs b/.claude/skills/web-test/scripts/cli/commands/status.mjs new file mode 100644 index 00000000..f183629f --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/commands/status.mjs @@ -0,0 +1,14 @@ +// web-test cli/commands/status v1.0 — check session +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { existsSync, readFileSync } from 'fs'; +import { out } from '../util.mjs'; +import { SESSION_FILE } from '../session.mjs'; + +export function cmdStatus() { + if (!existsSync(SESSION_FILE)) { + out({ ok: false, message: 'No active session' }); + process.exit(1); + } + const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8')); + out({ ok: true, ...sess }); +} diff --git a/.claude/skills/web-test/scripts/cli/commands/stop.mjs b/.claude/skills/web-test/scripts/cli/commands/stop.mjs new file mode 100644 index 00000000..456f15f4 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/commands/stop.mjs @@ -0,0 +1,17 @@ +// web-test cli/commands/stop v1.0 — send stop to server +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { out } from '../util.mjs'; +import { loadSession, cleanup } from '../session.mjs'; + +export async function cmdStop() { + const sess = loadSession(); + try { + const resp = await fetch(`http://127.0.0.1:${sess.port}/stop`, { method: 'POST' }); + const result = await resp.json(); + out(result); + } catch { + // Server may have already exited before responding + out({ ok: true, message: 'Stopped' }); + } + cleanup(); +} diff --git a/.claude/skills/web-test/scripts/cli/commands/test.mjs b/.claude/skills/web-test/scripts/cli/commands/test.mjs new file mode 100644 index 00000000..3eed600f --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/commands/test.mjs @@ -0,0 +1,433 @@ +// web-test cli/commands/test v1.0 — regression test runner +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { existsSync, writeFileSync, mkdirSync } from 'fs'; +import { resolve, dirname, basename, relative } from 'path'; +import * as browser from '../../browser.mjs'; +import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps } from '../util.mjs'; +import { buildContext, buildScopedContext } from '../exec-context.mjs'; +import { createAssertions } from '../test-runner/assertions.mjs'; +import { buildSeverityIndex } from '../test-runner/severity.mjs'; +import { writeAllure, buildJUnit, syncAllureExtras } from '../test-runner/reporters.mjs'; +import { discoverTests, resetState } from '../test-runner/discover.mjs'; + +export async function cmdTest(rawArgs) { + // Split off everything after `--` — those args belong to user-defined hooks + // (see spec §6: "all arguments after `--` are forwarded verbatim to _hooks.mjs + // via the hookArgs field; the runner does not interpret them"). + const sepIdx = rawArgs.indexOf('--'); + const ownArgs = sepIdx >= 0 ? rawArgs.slice(0, sepIdx) : rawArgs; + const hookArgs = sepIdx >= 0 ? rawArgs.slice(sepIdx + 1) : []; + + // Parse flags + const opts = { bail: false, retry: 0, timeout: 30000, report: null, format: 'json', screenshot: null, reportDir: null, record: false }; + let tags = null, grep = null; + const positional = []; + for (const a of ownArgs) { + if (a.startsWith('--tags=')) tags = a.slice(7).split(','); + else if (a.startsWith('--grep=')) grep = new RegExp(a.slice(7), 'i'); + else if (a === '--bail') opts.bail = true; + else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0; + else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000; + else if (a.startsWith('--report=')) opts.report = a.slice(9); + else if (a.startsWith('--format=')) opts.format = a.slice(9); + else if (a.startsWith('--screenshot=')) opts.screenshot = a.slice(13); + else if (a.startsWith('--report-dir=')) opts.reportDir = a.slice(13); + else if (a === '--record') opts.record = true; + else if (!a.startsWith('--')) positional.push(a); + } + + // Determine URL and test path + let url, testPath; + if (positional.length === 2) { + url = positional[0]; + testPath = resolve(positional[1]); + } else if (positional.length === 1) { + testPath = resolve(positional[0]); + } else { + die('Usage: node run.mjs test [url] [--tags=...] [--bail] [--retry=N] [--timeout=ms] [--report=path]'); + } + + // Load config if exists + const isFile = testPath.endsWith('.test.mjs'); + const testDir = isFile ? dirname(testPath) : testPath; + const configPath = resolve(testDir, 'webtest.config.mjs'); + let config = {}; + if (existsSync(configPath)) { + const mod = await import('file:///' + configPath.replace(/\\/g, '/')); + config = mod.default || {}; + } + const severityIndex = buildSeverityIndex(config); + + // Build context registry: name → url. Supports config.contexts or single config.url / CLI url. + const contextSpecs = {}; + let defaultContextName = 'default'; + const defaultIsolation = config.isolation || 'tab'; + if (config.contexts && typeof config.contexts === 'object' && Object.keys(config.contexts).length) { + for (const [n, spec] of Object.entries(config.contexts)) { + contextSpecs[n] = { ...spec }; + } + defaultContextName = config.defaultContext || Object.keys(config.contexts)[0]; + if (url) contextSpecs[defaultContextName] = { ...contextSpecs[defaultContextName], url }; + } else { + const fallbackUrl = url || config.url; + if (!fallbackUrl) die('No URL provided and no webtest.config.mjs found'); + contextSpecs.default = { url: fallbackUrl }; + } + if (!contextSpecs[defaultContextName]) { + die(`defaultContext "${defaultContextName}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`); + } + if (!url) url = contextSpecs[defaultContextName].url; + + // Apply config defaults (CLI flags override) + if (!tags && config.tags) tags = config.tags; + opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout); + opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry); + if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) { + browser.setPreserveClipboard(false); + } + opts.record = opts.record || !!config.record; + opts.screenshot = opts.screenshot || config.screenshot || 'on-failure'; + if (!['on-failure', 'every-step', 'off'].includes(opts.screenshot)) { + die(`Invalid --screenshot=${opts.screenshot} (expected on-failure|every-step|off)`); + } + if (!['json', 'allure', 'junit'].includes(opts.format)) { + die(`Invalid --format=${opts.format} (expected json|allure|junit)`); + } + if (opts.format === 'junit' && !opts.report) { + die('--format=junit requires --report=path.xml'); + } + const reportDir = opts.reportDir + ? resolve(opts.reportDir) + : (opts.report ? dirname(resolve(opts.report)) : testDir); + if (opts.screenshot !== 'off') { + try { mkdirSync(reportDir, { recursive: true }); } catch {} + } + + // Discover test files + const testFiles = discoverTests(testPath); + if (!testFiles.length) die(`No *.test.mjs files found in ${testPath}`); + + // Import and filter tests + const tests = []; + let hasOnly = false; + for (const file of testFiles) { + const mod = await import('file:///' + file.replace(/\\/g, '/')); + const base = { + file: relative(testDir, file).replace(/\\/g, '/'), + name: mod.name || basename(file, '.test.mjs'), + tags: mod.tags || [], + timeout: mod.timeout || opts.timeout, + skip: mod.skip || false, + only: mod.only || false, + setup: mod.setup, + teardown: mod.teardown, + fn: mod.default, + param: undefined, + context: mod.context || null, + contexts: Array.isArray(mod.contexts) ? mod.contexts : null, + severity: typeof mod.severity === 'string' ? mod.severity : null, + }; + if (base.only) hasOnly = true; + if (Array.isArray(mod.params) && mod.params.length) { + for (let i = 0; i < mod.params.length; i++) { + const p = mod.params[i]; + const name = base.name.includes('{') ? interpolate(base.name, p) : `${base.name}[${i}]`; + tests.push({ ...base, name, param: p }); + } + } else { + tests.push(base); + } + } + + // Filter + const filtered = tests.filter(t => { + if (hasOnly && !t.only) return false; + if (tags && !tags.some(tag => t.tags.includes(tag))) return false; + if (grep && !grep.test(t.name)) return false; + return true; + }); + + // Load hooks + const hooksPath = resolve(testDir, '_hooks.mjs'); + let hooks = {}; + if (existsSync(hooksPath)) { + hooks = await import('file:///' + hooksPath.replace(/\\/g, '/')); + } + + // Console header + const W = process.stderr; + W.write(`\nweb-test -- ${url}\n`); + W.write(`Running ${filtered.length} tests from ${relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'}/\n\n`); + + const startedAt = new Date().toISOString(); + const results = []; + let passCount = 0, failCount = 0, skipCount = 0; + + const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`); + const hookEnv = { hookArgs, log: hookLog, config }; + if (hooks.prepare) await hooks.prepare(hookEnv); + + // Lazy context creation + async function ensureContext(name) { + if (browser.hasContext(name)) return; + const spec = contextSpecs[name]; + if (!spec) throw new Error(`Unknown context "${name}". Defined: [${Object.keys(contextSpecs).join(', ')}]`); + await browser.createContext(name, spec.url, { isolation: spec.isolation || defaultIsolation }); + if (hooks.afterOpenContext && hookCtx) { + try { await hooks.afterOpenContext(hookCtx, name, spec); } + catch (e) { hookLog(`afterOpenContext("${name}") threw: ${e.message.split('\n')[0]}`); } + } + } + + let hookCtx = null; + + function wrapCloseContextHook(target) { + const orig = target.closeContext; + if (typeof orig !== 'function') return; + target.closeContext = async (name) => { + if (hooks.beforeCloseContext) { + try { await hooks.beforeCloseContext(target, name, contextSpecs[name]); } + catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); } + } + return await orig(name); + }; + } + + try { + // Connect: create default context up front + await ensureContext(defaultContextName); + + const ctx = buildContext({ noRecord: false }); + ctx.assert = createAssertions(); + ctx.log = (...a) => { /* per-test, overridden below */ }; + wrapCloseContextHook(ctx); + hookCtx = ctx; + + // Default context was created BEFORE hookCtx existed → fire afterOpenContext now. + if (hooks.afterOpenContext) { + try { await hooks.afterOpenContext(ctx, defaultContextName, contextSpecs[defaultContextName]); } + catch (e) { hookLog(`afterOpenContext("${defaultContextName}") threw: ${e.message.split('\n')[0]}`); } + } + + if (hooks.beforeAll) await hooks.beforeAll(ctx); + + let testIdx = 0; + for (const t of filtered) { + testIdx++; + const declaredContexts = t.contexts && t.contexts.length + ? t.contexts + : [t.context || defaultContextName]; + + if (t.skip) { + const reason = typeof t.skip === 'string' ? t.skip : ''; + W.write(` ○ ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`); + results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null }); + skipCount++; + continue; + } + + const testContextNames = declaredContexts; + try { + for (const cn of testContextNames) await ensureContext(cn); + await browser.setActiveContext(testContextNames[0]); + } catch (e) { + W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`); + results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null }); + failCount++; + if (opts.bail) break; + continue; + } + + let lastError = null; + let testResult = null; + const maxAttempts = 1 + opts.retry; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const output = []; + let steps = []; + let currentSteps = steps; + let stepIdx = 0; + const t0 = Date.now(); + + ctx.testInfo = { + name: t.name, + file: basename(t.file), + filePath: t.file, + tags: t.tags, + timeout: t.timeout, + attempt, + maxAttempts, + param: t.param, + contexts: Object.fromEntries(testContextNames.map(n => [n, contextSpecs[n]])), + primaryContext: testContextNames[0], + }; + ctx.testResult = null; + + let videoFile = null; + if (opts.record) { + videoFile = resolve(reportDir, `${testIdx}-${slugify(t.name)}.mp4`); + try { await browser.startRecording(videoFile, { force: true }); } catch { videoFile = null; } + } + + ctx.log = (...a) => output.push(a.map(String).join(' ')); + ctx.step = async (name, fn) => { + const s = { name, start: Date.now(), status: 'passed', steps: [] }; + currentSteps.push(s); + const prev = currentSteps; + currentSteps = s.steps; + stepIdx++; + const myIdx = stepIdx; + try { + await fn(); + } catch (e) { + s.status = 'failed'; + s.error = e.message; + throw e; + } finally { + s.stop = Date.now(); + currentSteps = prev; + if (opts.screenshot === 'every-step' && s.status === 'passed') { + try { + const slug = slugify(name); + const file = resolve(reportDir, `${testIdx}-${myIdx}-${slug}.png`); + const png = await browser.screenshot(); + writeFileSync(file, png); + s.screenshot = file; + } catch {} + } + } + }; + + const scopedKeys = []; + if (t.contexts && t.contexts.length) { + for (const cn of t.contexts) { + ctx[cn] = buildScopedContext(cn); + wrapCloseContextHook(ctx[cn]); + scopedKeys.push(cn); + } + } + + try { + if (hooks.beforeEach) await hooks.beforeEach(ctx); + if (t.setup) await t.setup(ctx); + + await Promise.race([ + t.fn(ctx, t.param), + new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout (${t.timeout}ms)`)), t.timeout)), + ]); + + if (t.teardown) try { await t.teardown(ctx); } catch {} + ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps }; + if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {} + for (const cn of testContextNames) { + try { await browser.setActiveContext(cn); await resetState(ctx); } catch {} + } + for (const k of scopedKeys) delete ctx[k]; + + if (videoFile) { + try { await browser.stopRecording(); } catch {} + } + const dur = elapsed(t0); + testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile }; + lastError = null; + break; + + } catch (e) { + // Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI. + let shotFile = e.onecError?.screenshot; + if (!shotFile && opts.screenshot !== 'off') { + try { + const png = await browser.screenshot(); + shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`); + writeFileSync(shotFile, png); + } catch {} + } + + if (t.teardown) try { await t.teardown(ctx); } catch {} + const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError }; + ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps }; + if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {} + for (const cn of testContextNames) { + try { await browser.setActiveContext(cn); await resetState(ctx); } catch {} + } + for (const k of scopedKeys) delete ctx[k]; + + if (videoFile) { + try { await browser.stopRecording(); } catch {} + } + lastError = errInfo; + const dur = elapsed(t0); + testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: errInfo, screenshot: shotFile, video: videoFile }; + } + } + + results.push(testResult); + + if (testResult.status === 'passed') { + passCount++; + W.write(` ✓ ${t.name} (${testResult.duration}s)\n`); + } else { + failCount++; + W.write(` ✗ ${t.name} (${testResult.duration}s)\n`); + printSteps(W, testResult.steps, ' '); + if (lastError?.message) W.write(` ${lastError.message}\n`); + if (lastError?.screenshot) W.write(` screenshot: ${lastError.screenshot}\n`); + } + + if (opts.bail && testResult.status === 'failed') break; + } + + if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {} + + } finally { + // Per-context teardown + try { + const remaining = browser.listContexts(); + if (remaining.length > 0) { + const survivor = remaining[0]; + try { await browser.setActiveContext(survivor); } catch {} + for (let i = remaining.length - 1; i >= 1; i--) { + const name = remaining[i]; + if (hooks.beforeCloseContext && hookCtx) { + try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); } + catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); } + } + try { await browser.closeContext(name); } + catch (e) { hookLog(`closeContext("${name}") failed: ${e.message.split('\n')[0]}`); } + } + if (hooks.beforeCloseContext && hookCtx) { + try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); } + catch (e) { hookLog(`beforeCloseContext("${survivor}") threw: ${e.message.split('\n')[0]}`); } + } + } + } catch (e) { + hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`); + } + try { await browser.disconnect(); } catch {} + if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {} + } + + const finishedAt = new Date().toISOString(); + const totalDuration = results.reduce((s, r) => s + r.duration, 0); + + W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`); + + const report = { + runner: 'web-test', url, startedAt, finishedAt, + duration: totalDuration, + summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount }, + tests: results, + }; + out(report); + + if (opts.format === 'allure') { + writeAllure(results, reportDir, severityIndex); + syncAllureExtras(testDir, reportDir); + } else if (opts.format === 'junit') { + writeFileSync(resolve(opts.report), buildJUnit(report, testDir)); + } else if (opts.report) { + writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2)); + } + + if (failCount > 0) process.exit(1); +} diff --git a/.claude/skills/web-test/scripts/cli/exec-context.mjs b/.claude/skills/web-test/scripts/cli/exec-context.mjs new file mode 100644 index 00000000..a6c0964f --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/exec-context.mjs @@ -0,0 +1,148 @@ +// web-test cli/exec-context v1.0 — buildContext + executeScript для run/exec/test +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import * as browser from '../browser.mjs'; +import { elapsed } from './util.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ERROR_SHOT_PATH = resolve(__dirname, '..', '..', 'error-shot.png'); + +/** + * Build a per-context wrapper: same shape as buildContext output, but every call + * is prefixed with `setActiveContext(name)` so the test can interleave actions + * across contexts (`ctx.a.click(...); ctx.b.click(...)`). + */ +export function buildScopedContext(name) { + const inner = buildContext({ noRecord: false }); + const scoped = {}; + for (const [k, v] of Object.entries(inner)) { + if (typeof v === 'function') { + scoped[k] = async (...args) => { + await browser.setActiveContext(name); + return v(...args); + }; + } else { + scoped[k] = v; + } + } + return scoped; +} + +export function buildContext({ noRecord = false } = {}) { + const ctx = {}; + for (const [k, v] of Object.entries(browser)) { + if (k !== 'default') ctx[k] = v; + } + ctx.writeFileSync = writeFileSync; + ctx.readFileSync = readFileSync; + + // --no-record: stub recording/narration functions to return safe defaults + if (noRecord) { + const noop = async () => {}; + ctx.startRecording = noop; + ctx.stopRecording = async () => ({ file: null, duration: 0, size: 0 }); + ctx.addNarration = async () => ({ file: null, duration: 0, size: 0, captions: 0 }); + for (const fn of ['showCaption', 'hideCaption']) { + ctx[fn] = noop; + } + ctx.isRecording = () => false; + ctx.getCaptions = () => []; + } + + // Wrap action functions to auto-detect 1C errors (modal, balloon) + // and stop execution immediately with diagnostic info + const ACTION_FNS = [ + 'clickElement', 'fillFields', 'fillField', 'selectValue', 'fillTableRow', + 'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile', + 'closeForm', 'filterList', 'unfilterList' + ]; + for (const name of ACTION_FNS) { + if (typeof ctx[name] !== 'function') continue; + const orig = ctx[name]; + ctx[name] = async (...args) => { + const result = await orig(...args); + const errors = result?.errors; + if (errors?.modal || errors?.balloon) { + // Screenshot while the error modal is still visible (before fetchErrorStack closes it) + let errorShot; + try { + const png = await ctx.screenshot(); + errorShot = ERROR_SHOT_PATH; + writeFileSync(errorShot, png); + } catch {} + // Try to fetch call stack for modal errors before throwing + let stack = null; + if (errors?.modal && typeof ctx.fetchErrorStack === 'function') { + try { + stack = await ctx.fetchErrorStack(errors.modal.formNum, errors.modal.hasReport); + } catch { /* don't fail if stack fetch fails */ } + } + const msg = errors.modal?.message || errors.balloon?.message || 'Unknown 1C error'; + const err = new Error(msg); + err.onecError = { step: name, args, errors, formState: result, stack, screenshot: errorShot }; + throw err; + } + return result; + }; + } + + return ctx; +} + +export async function executeScript(code, { noRecord } = {}) { + const output = []; + const origLog = console.log; + const origErr = console.error; + console.log = (...a) => output.push(a.map(String).join(' ')); + console.error = (...a) => output.push('[ERR] ' + a.map(String).join(' ')); + + const t0 = Date.now(); + try { + const ctx = buildContext({ noRecord }); + + // Normalize Windows backslash paths to prevent JS parse errors + // (e.g. C:\Users\... → \u triggers "Invalid Unicode escape sequence") + code = code.replace(/[A-Za-z]:\\[^\s'"`;\n)}\]]+/g, m => m.replace(/\\/g, '/')); + + const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; + const fn = new AsyncFunction(...Object.keys(ctx), code); + await fn(...Object.values(ctx)); + + console.log = origLog; + console.error = origErr; + return { ok: true, output: output.join('\n'), elapsed: elapsed(t0) }; + } catch (e) { + console.log = origLog; + console.error = origErr; + + // Auto-stop recording if active (prevents "Already recording" on next exec) + if (browser.isRecording()) { + try { await browser.stopRecording(); } catch {} + } + + // Error screenshot (skip if already taken before fetchErrorStack closed the modal) + let shotFile = e.onecError?.screenshot; + if (!shotFile) { + try { + const png = await browser.screenshot(); + shotFile = ERROR_SHOT_PATH; + writeFileSync(shotFile, png); + } catch {} + } + + const result = { ok: false, error: e.message, output: output.join('\n'), screenshot: shotFile, elapsed: elapsed(t0) }; + + // Enrich with 1C error context if available + if (e.onecError) { + result.step = e.onecError.step; + result.stepArgs = e.onecError.args; + result.onecErrors = e.onecError.errors; + result.formState = e.onecError.formState; + if (e.onecError.stack) result.stack = e.onecError.stack; + } + + return result; + } +} diff --git a/.claude/skills/web-test/scripts/cli/server.mjs b/.claude/skills/web-test/scripts/cli/server.mjs new file mode 100644 index 00000000..b8a75dc3 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/server.mjs @@ -0,0 +1,37 @@ +// web-test cli/server v1.0 — HTTP server для exec/shot/stop/status в процессе start +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import * as browser from '../browser.mjs'; +import { json, readBody } from './util.mjs'; +import { cleanup } from './session.mjs'; +import { executeScript } from './exec-context.mjs'; + +export async function handleRequest(req, res) { + try { + if (req.method === 'POST' && req.url === '/exec') { + const code = await readBody(req); + const noRecord = req.headers['x-no-record'] === '1'; + const result = await executeScript(code, { noRecord }); + json(res, result); + + } else if (req.method === 'GET' && req.url === '/shot') { + const png = await browser.screenshot(); + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(png); + + } else if (req.method === 'POST' && req.url === '/stop') { + json(res, { ok: true, message: 'Stopping' }); + await browser.disconnect(); + cleanup(); + process.exit(0); + + } else if (req.method === 'GET' && req.url === '/status') { + json(res, { ok: true, connected: browser.isConnected() }); + + } else { + res.writeHead(404); + res.end('Not found'); + } + } catch (e) { + json(res, { ok: false, error: e.message }, 500); + } +} diff --git a/.claude/skills/web-test/scripts/cli/session.mjs b/.claude/skills/web-test/scripts/cli/session.mjs new file mode 100644 index 00000000..b4008f3f --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/session.mjs @@ -0,0 +1,20 @@ +// web-test cli/session v1.0 — session-file helpers for HTTP-server mode +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { existsSync, readFileSync, unlinkSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { die } from './util.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const SESSION_FILE = resolve(__dirname, '..', '..', '.browser-session.json'); + +export function loadSession() { + if (!existsSync(SESSION_FILE)) { + die('No active session. Run: node src/run.mjs start '); + } + return JSON.parse(readFileSync(SESSION_FILE, 'utf-8')); +} + +export function cleanup() { + try { unlinkSync(SESSION_FILE); } catch {} +} diff --git a/.claude/skills/web-test/scripts/cli/test-runner/assertions.mjs b/.claude/skills/web-test/scripts/cli/test-runner/assertions.mjs new file mode 100644 index 00000000..23afb2d0 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/test-runner/assertions.mjs @@ -0,0 +1,64 @@ +// web-test cli/test-runner/assertions v1.0 — ctx.assert API +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +export function createAssertions() { + class AssertionError extends Error { + constructor(msg, actual, expected) { + super(msg); + this.name = 'AssertionError'; + this.actual = actual; + this.expected = expected; + } + } + + return { + ok(value, msg) { + if (!value) throw new AssertionError(msg || `Expected truthy, got ${JSON.stringify(value)}`, value, true); + }, + equal(actual, expected, msg) { + if (actual !== expected) throw new AssertionError(msg || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, actual, expected); + }, + notEqual(actual, expected, msg) { + if (actual === expected) throw new AssertionError(msg || `Expected not ${JSON.stringify(expected)}`, actual, expected); + }, + deepEqual(actual, expected, msg) { + const a = JSON.stringify(actual), b = JSON.stringify(expected); + if (a !== b) throw new AssertionError(msg || `Deep equal failed:\n actual: ${a}\n expected: ${b}`, actual, expected); + }, + includes(haystack, needle, msg) { + const h = Array.isArray(haystack) ? haystack : String(haystack); + if (!h.includes(needle)) throw new AssertionError(msg || `Expected ${JSON.stringify(h)} to include ${JSON.stringify(needle)}`, haystack, needle); + }, + match(string, regex, msg) { + if (!regex.test(string)) throw new AssertionError(msg || `Expected ${JSON.stringify(string)} to match ${regex}`, string, regex); + }, + async throws(fn, msg) { + try { await fn(); } catch { return; } + throw new AssertionError(msg || 'Expected function to throw'); + }, + // 1C-specific + formHasField(state, fieldName, msg) { + if (!state?.fields?.[fieldName]) throw new AssertionError(msg || `Field "${fieldName}" not found in form. Available: ${Object.keys(state?.fields || {}).join(', ')}`, null, fieldName); + }, + formTitle(state, expected, msg) { + if (!state?.title?.includes(expected)) throw new AssertionError(msg || `Form title "${state?.title}" does not contain "${expected}"`, state?.title, expected); + }, + tableHasRow(table, predicate, msg) { + const rows = table?.rows || []; + let found; + if (typeof predicate === 'function') { + found = rows.some(predicate); + } else { + found = rows.some(r => Object.entries(predicate).every(([k, v]) => r[k] === v)); + } + if (!found) throw new AssertionError(msg || `No row matching predicate in table (${rows.length} rows)`, null, predicate); + }, + tableRowCount(table, expected, msg) { + const actual = table?.rows?.length ?? 0; + if (actual !== expected) throw new AssertionError(msg || `Expected ${expected} rows, got ${actual}`, actual, expected); + }, + noErrors(state, msg) { + if (state?.errors) throw new AssertionError(msg || `Form has errors: ${JSON.stringify(state.errors)}`, state.errors, null); + }, + }; +} diff --git a/.claude/skills/web-test/scripts/cli/test-runner/discover.mjs b/.claude/skills/web-test/scripts/cli/test-runner/discover.mjs new file mode 100644 index 00000000..bffbe8e9 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/test-runner/discover.mjs @@ -0,0 +1,32 @@ +// web-test cli/test-runner/discover v1.0 — test file discovery + state reset between tests +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { existsSync, readdirSync } from 'fs'; +import { resolve } from 'path'; + +export function discoverTests(testPath) { + if (testPath.endsWith('.test.mjs')) return existsSync(testPath) ? [testPath] : []; + const files = []; + function walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue; + const full = resolve(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.test.mjs')) files.push(full); + } + } + walk(testPath); + return files.sort(); +} + +export async function resetState(ctx) { + try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {} + for (let i = 0; i < 10; i++) { + try { + const state = await ctx.getFormState(); + // form === null means no form open (desktop). form === 0 is a real background form + // 1C exposes in some states — must still close it to fully reset. + if (state.form == null) break; + await ctx.closeForm({ save: false }); + } catch { break; } + } +} diff --git a/.claude/skills/web-test/scripts/cli/test-runner/reporters.mjs b/.claude/skills/web-test/scripts/cli/test-runner/reporters.mjs new file mode 100644 index 00000000..2a4cab29 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/test-runner/reporters.mjs @@ -0,0 +1,113 @@ +// web-test cli/test-runner/reporters v1.0 — Allure/JUnit writers + extras sync +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { writeFileSync, existsSync, readdirSync, copyFileSync, statSync } from 'fs'; +import { resolve, dirname, basename, relative } from 'path'; +import { randomUUID } from 'crypto'; +import { xmlEscape } from '../util.mjs'; +import { resolveSeverity } from './severity.mjs'; + +/** + * Copy any files from `/_allure/` into `reportDir`. Convention for + * Allure customization that doesn't fit inside per-test JSON: + * - `categories.json` — failure classification (regex → bucket) + * - `environment.properties` — values shown in the Environment widget + * - `executor.json` — CI/CD metadata + * Underscored folder mirrors `_hooks.mjs` convention (infra, not a test). + * Silent if folder absent. + */ +export function syncAllureExtras(testDir, reportDir) { + const extrasDir = resolve(testDir, '_allure'); + if (!existsSync(extrasDir)) return; + try { + if (!statSync(extrasDir).isDirectory()) return; + } catch { return; } + for (const entry of readdirSync(extrasDir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + try { copyFileSync(resolve(extrasDir, entry.name), resolve(reportDir, entry.name)); } + catch { /* best-effort */ } + } +} + +export function writeAllure(results, reportDir, severityIndex) { + for (const tr of results) { + if (tr.status === 'skipped') continue; // Allure ignores skipped without start/stop + const uuid = randomUUID(); + const suite = dirname(tr.file); + const suiteLabel = (suite && suite !== '.') ? suite : 'root'; + const severity = resolveSeverity(tr, severityIndex); + const out = { + uuid, + name: tr.name, + fullName: tr.file, + status: tr.status, + stage: 'finished', + start: tr.start, + stop: tr.stop, + labels: [ + ...(tr.tags || []).map(t => ({ name: 'tag', value: t })), + { name: 'suite', value: suiteLabel }, + { name: 'severity', value: severity }, + ], + steps: (tr.steps || []).map(allureStep), + attachments: [ + ...(tr.screenshot ? [{ name: 'Screenshot on failure', source: basename(tr.screenshot), type: 'image/png' }] : []), + ...(tr.video ? [{ name: 'Video', source: basename(tr.video), type: 'video/mp4' }] : []), + ], + }; + if (tr.status === 'failed' && tr.error) { + const traceParts = []; + if (tr.output) traceParts.push(tr.output); + const onecStack = tr.error.onecError?.stack?.raw; + if (onecStack) { + if (traceParts.length) traceParts.push('\n--- 1C stack ---\n'); + traceParts.push(onecStack); + } + out.statusDetails = { message: tr.error.message || '', trace: traceParts.join('') }; + } + writeFileSync(resolve(reportDir, `${uuid}-result.json`), JSON.stringify(out, null, 2)); + } +} + +function allureStep(s) { + const out = { + name: s.name, + status: s.status, + stage: 'finished', + start: s.start, + stop: s.stop, + steps: (s.steps || []).map(allureStep), + }; + if (s.screenshot) { + out.attachments = [{ name: 'Screenshot', source: basename(s.screenshot), type: 'image/png' }]; + } + if (s.status === 'failed' && s.error) { + out.statusDetails = { message: s.error, trace: s.error }; + } + return out; +} + +export function buildJUnit(report, testDir) { + const { summary, duration, tests } = report; + const suiteName = relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'; + const lines = ['']; + lines.push(``); + lines.push(` `); + for (const t of tests) { + const attrs = `name="${xmlEscape(t.name)}" classname="${xmlEscape(t.file)}" time="${(t.duration || 0).toFixed(3)}"`; + if (t.status === 'passed') { + lines.push(` `); + } else if (t.status === 'skipped') { + lines.push(` `); + } else { + lines.push(` `); + const msg = t.error?.message || ''; + const trace = t.output || ''; + lines.push(` ${xmlEscape(trace)}`); + if (t.screenshot) lines.push(` screenshot: ${xmlEscape(t.screenshot)}`); + lines.push(` `); + } + } + lines.push(` `); + lines.push(``); + return lines.join('\n'); +} diff --git a/.claude/skills/web-test/scripts/cli/test-runner/severity.mjs b/.claude/skills/web-test/scripts/cli/test-runner/severity.mjs new file mode 100644 index 00000000..d76b753d --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/test-runner/severity.mjs @@ -0,0 +1,66 @@ +// web-test cli/test-runner/severity v1.0 — Allure severity policy resolver +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { die } from '../util.mjs'; + +export const SEVERITY_RANK = { blocker: 5, critical: 4, normal: 3, minor: 2, trivial: 1 }; +export const SEVERITY_LEVELS = Object.keys(SEVERITY_RANK); + +/** + * Validate config.severity (inverted map: severity → [tags]) at config load time. + * Returns: + * - tagToSeverity: Map (precomputed lookup for the resolver) + * - defaultSeverity: string (validated, defaults to 'normal') + * Throws (via die) on invalid keys, invalid default, or duplicate tag across buckets. + */ +export function buildSeverityIndex(config) { + const tagToSeverity = new Map(); + const sev = config.severity || {}; + if (typeof sev !== 'object' || Array.isArray(sev)) { + die(`config.severity must be an object, got ${typeof sev}`); + } + for (const [level, tags] of Object.entries(sev)) { + if (!SEVERITY_LEVELS.includes(level)) { + die(`config.severity: unknown level "${level}". Allowed: ${SEVERITY_LEVELS.join('|')}`); + } + if (!Array.isArray(tags)) { + die(`config.severity.${level} must be an array of tag names, got ${typeof tags}`); + } + for (const tag of tags) { + if (tagToSeverity.has(tag)) { + die(`config.severity: tag "${tag}" listed under both "${tagToSeverity.get(tag)}" and "${level}" — pick one`); + } + tagToSeverity.set(tag, level); + } + } + const def = config.defaultSeverity || 'normal'; + if (!SEVERITY_LEVELS.includes(def)) { + die(`config.defaultSeverity: "${def}" is not a valid level. Allowed: ${SEVERITY_LEVELS.join('|')}`); + } + return { tagToSeverity, defaultSeverity: def }; +} + +/** + * Resolve a test's severity. Precedence: + * 1. explicit `export const severity` from the test module + * 2. max-rank severity found among tags (either standard severity name, or mapped via config) + * 3. defaultSeverity from config (or 'normal' if not set) + * Returns one of SEVERITY_LEVELS. + */ +export function resolveSeverity(t, severityIndex) { + if (t.severity) { + if (!SEVERITY_LEVELS.includes(t.severity)) { + return severityIndex.defaultSeverity; + } + return t.severity; + } + let best = null; + for (const tag of t.tags || []) { + let candidate = null; + if (SEVERITY_LEVELS.includes(tag)) candidate = tag; + else if (severityIndex.tagToSeverity.has(tag)) candidate = severityIndex.tagToSeverity.get(tag); + if (candidate && (best === null || SEVERITY_RANK[candidate] > SEVERITY_RANK[best])) { + best = candidate; + } + } + return best || severityIndex.defaultSeverity; +} diff --git a/.claude/skills/web-test/scripts/cli/util.mjs b/.claude/skills/web-test/scripts/cli/util.mjs new file mode 100644 index 00000000..c24a52e6 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/util.mjs @@ -0,0 +1,111 @@ +// web-test cli/util v1.0 — generic helpers for CLI commands +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +export function out(obj) { + process.stdout.write(JSON.stringify(obj, null, 2) + '\n'); +} + +export function die(msg) { + process.stderr.write(msg + '\n'); + process.exit(1); +} + +export function json(res, obj, status = 200) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(obj, null, 2)); +} + +export async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf-8'); +} + +export async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf-8'); +} + +export function elapsed(t0) { + return Math.round((Date.now() - t0) / 100) / 10; +} + +export function elapsed2(start, stop) { + return Math.round(((stop || Date.now()) - start) / 100) / 10; +} + +export function slugify(s) { + return String(s).trim() + .replace(/[\s/\\:*?"<>|]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 60) || 'step'; +} + +export function formatDuration(seconds) { + if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`; + const m = Math.floor(seconds / 60); + const s = Math.round((seconds - m * 60) * 10) / 10; + return `${m}m ${s}s`; +} + +export function xmlEscape(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +export function interpolate(template, params) { + return String(template).replace(/\{(\w+)\}/g, (_, key) => + params[key] !== undefined ? String(params[key]) : `{${key}}`); +} + +export function printSteps(W, steps, indent) { + for (let i = 0; i < steps.length; i++) { + const s = steps[i]; + const last = i === steps.length - 1; + const prefix = last ? '└' : '├'; + const mark = s.status === 'failed' ? '✗ ' : ''; + W.write(`${indent}${prefix} ${mark}${s.name} (${elapsed2(s.start, s.stop)}s)\n`); + if (s.error && s.status === 'failed') { + W.write(`${indent} ${s.error}\n`); + } + if (s.steps.length) printSteps(W, s.steps, indent + ' '); + } +} + +export function usage() { + die(`Usage: node run.mjs [args] + +Commands: + start Launch browser and connect to 1C web client + run Autonomous: connect, execute script, disconnect + exec [options] Execute script (file path or - for stdin) + shot [file] Take screenshot (default: shot.png) + stop Logout and close browser + status Check session status + test [url] Run regression tests (*.test.mjs) + +Options for exec: + --no-record Skip video recording (record() becomes no-op) + +Global options (any command): + --no-preserve-clipboard Don't save/restore OS clipboard around action calls. + Default: on (env: WEB_TEST_PRESERVE_CLIPBOARD=0 to disable globally). + +Options for test: + --tags=smoke,crud Filter tests by tags + --grep=pattern Filter tests by name (regex) + --bail Stop on first failure + --retry=N Retry failed tests N times + --timeout=ms Per-test timeout (default: 30000) + --report=path Write JSON report to file + --report-dir=path Directory for screenshots and other artifacts + --screenshot=mode on-failure (default) | every-step | off + --format=fmt json (default) | allure | junit + --record Record video for each test (mp4 in report-dir) + -- Everything after \`--\` is forwarded to _hooks.mjs + prepare/cleanup as hookArgs (runner does not parse it). + Example: ... tests/web-test/ -- --rebuild-stand`); +} diff --git a/.claude/skills/web-test/scripts/run.mjs b/.claude/skills/web-test/scripts/run.mjs index 74cf6a12..7fb2e1e6 100644 --- a/.claude/skills/web-test/scripts/run.mjs +++ b/.claude/skills/web-test/scripts/run.mjs @@ -1,1258 +1,65 @@ -#!/usr/bin/env node -// web-test run v1.16 — CLI runner for 1C web client automation -// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills -/** - * CLI runner for 1C web client automation. - * - * Architecture: `start` launches browser + HTTP server in one process. - * `exec`, `shot`, `stop` send requests to the running server. - * - * Usage: - * node src/run.mjs start — launch browser, connect to 1C, serve requests - * node src/run.mjs run — autonomous: connect, execute script, disconnect - * node src/run.mjs exec — run script against existing session - * node src/run.mjs shot [file] — take screenshot - * node src/run.mjs stop — logout + close browser - * node src/run.mjs status — check session - * node src/run.mjs test [url] — run regression tests - */ -import http from 'http'; -import * as browser from './browser.mjs'; -import { readFileSync, writeFileSync, unlinkSync, existsSync, readdirSync, mkdirSync, copyFileSync, statSync } from 'fs'; -import { resolve, dirname, basename, relative } from 'path'; -import { fileURLToPath } from 'url'; -import { randomUUID } from 'crypto'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const SESSION_FILE = resolve(__dirname, '..', '.browser-session.json'); - -// Allure severity policy. Declared early so buildSeverityIndex (called inside -// cmdTest) can use these constants — top-level const are not hoisted, and -// cmdTest is invoked synchronously below via `await cmdTest(rawArgs)`. -const SEVERITY_RANK = { blocker: 5, critical: 4, normal: 3, minor: 2, trivial: 1 }; -const SEVERITY_LEVELS = Object.keys(SEVERITY_RANK); - -const [,, cmd, ...rawArgs] = process.argv; -const flags = { - noRecord: rawArgs.includes('--no-record'), - execTimeoutMs: parseExecTimeoutMs(rawArgs), -}; -const args = rawArgs.filter(a => !a.startsWith('--')); - -// Clipboard preservation: default ON. Disabled by --no-preserve-clipboard CLI flag -// or WEB_TEST_PRESERVE_CLIPBOARD=0 env. cmdTest may further disable via config. -// Forwarded to browser.setPreserveClipboard() — narrow save/restore lives around -// each writeText+Ctrl+V pair inside pasteText() in browser.mjs. -const preserveClipboard = !rawArgs.includes('--no-preserve-clipboard') - && process.env.WEB_TEST_PRESERVE_CLIPBOARD !== '0'; -browser.setPreserveClipboard(preserveClipboard); - -function parseExecTimeoutMs(argv) { - const DEFAULT_MS = 30 * 60 * 1000; - const flagMs = argv.find(a => a.startsWith('--timeout=')); - if (flagMs) return Math.max(1, Number(flagMs.slice('--timeout='.length))) || DEFAULT_MS; - const flagMin = argv.find(a => a.startsWith('--timeout-min=')); - if (flagMin) return Math.max(1, Number(flagMin.slice('--timeout-min='.length))) * 60 * 1000 || DEFAULT_MS; - const env = process.env.WEB_TEST_EXEC_TIMEOUT_MS; - if (env) return Math.max(1, Number(env)) || DEFAULT_MS; - return DEFAULT_MS; -} - -switch (cmd) { - case 'start': await cmdStart(args[0]); break; - case 'run': await cmdRun(args[0], args[1]); break; - case 'exec': await cmdExec(args[0], flags); break; - case 'shot': await cmdShot(args[0]); break; - case 'stop': await cmdStop(); break; - case 'status': cmdStatus(); break; - case 'test': await cmdTest(rawArgs); break; - default: usage(); -} - - -// ============================================================ -// start: launch browser + HTTP server -// ============================================================ - -async function cmdStart(url) { - if (!url) die('Usage: node src/run.mjs start '); - - // Connect to 1C - const state = await browser.connect(url); - - // Start HTTP server for exec/shot/stop - const httpServer = http.createServer(handleRequest); - httpServer.listen(0, '127.0.0.1', () => { - const port = httpServer.address().port; - const session = { - port, - url, - pid: process.pid, - startedAt: new Date().toISOString() - }; - writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2)); - out({ ok: true, message: 'Browser ready', port, ...state }); - }); - - process.on('SIGINT', async () => { - await browser.disconnect(); - cleanup(); - process.exit(0); - }); -} - -async function handleRequest(req, res) { - try { - if (req.method === 'POST' && req.url === '/exec') { - const code = await readBody(req); - const noRecord = req.headers['x-no-record'] === '1'; - const result = await executeScript(code, { noRecord }); - json(res, result); - - } else if (req.method === 'GET' && req.url === '/shot') { - const png = await browser.screenshot(); - res.writeHead(200, { 'Content-Type': 'image/png' }); - res.end(png); - - } else if (req.method === 'POST' && req.url === '/stop') { - json(res, { ok: true, message: 'Stopping' }); - await browser.disconnect(); - cleanup(); - process.exit(0); - - } else if (req.method === 'GET' && req.url === '/status') { - json(res, { ok: true, connected: browser.isConnected() }); - - } else { - res.writeHead(404); - res.end('Not found'); - } - } catch (e) { - json(res, { ok: false, error: e.message }, 500); - } -} - -// ============================================================ -// buildContext: assemble browser API with error wrapping -// ============================================================ - -/** - * Build a per-context wrapper: same shape as buildContext output, but every call - * is prefixed with `setActiveContext(name)` so the test can interleave actions - * across contexts (`ctx.a.click(...); ctx.b.click(...)`). - */ -function buildScopedContext(name) { - const inner = buildContext({ noRecord: false }); - const scoped = {}; - for (const [k, v] of Object.entries(inner)) { - if (typeof v === 'function') { - scoped[k] = async (...args) => { - await browser.setActiveContext(name); - return v(...args); - }; - } else { - scoped[k] = v; - } - } - return scoped; -} - -function buildContext({ noRecord = false } = {}) { - const ctx = {}; - for (const [k, v] of Object.entries(browser)) { - if (k !== 'default') ctx[k] = v; - } - ctx.writeFileSync = writeFileSync; - ctx.readFileSync = readFileSync; - - // --no-record: stub recording/narration functions to return safe defaults - if (noRecord) { - const noop = async () => {}; - ctx.startRecording = noop; - ctx.stopRecording = async () => ({ file: null, duration: 0, size: 0 }); - ctx.addNarration = async () => ({ file: null, duration: 0, size: 0, captions: 0 }); - for (const fn of ['showCaption', 'hideCaption']) { - ctx[fn] = noop; - } - ctx.isRecording = () => false; - ctx.getCaptions = () => []; - } - - // Wrap action functions to auto-detect 1C errors (modal, balloon) - // and stop execution immediately with diagnostic info - const ACTION_FNS = [ - 'clickElement', 'fillFields', 'fillField', 'selectValue', 'fillTableRow', - 'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile', - 'closeForm', 'filterList', 'unfilterList' - ]; - for (const name of ACTION_FNS) { - if (typeof ctx[name] !== 'function') continue; - const orig = ctx[name]; - ctx[name] = async (...args) => { - const result = await orig(...args); - const errors = result?.errors; - if (errors?.modal || errors?.balloon) { - // Screenshot while the error modal is still visible (before fetchErrorStack closes it) - let errorShot; - try { - const png = await ctx.screenshot(); - errorShot = resolve(__dirname, '..', 'error-shot.png'); - writeFileSync(errorShot, png); - } catch {} - // Try to fetch call stack for modal errors before throwing - let stack = null; - if (errors?.modal && typeof ctx.fetchErrorStack === 'function') { - try { - stack = await ctx.fetchErrorStack(errors.modal.formNum, errors.modal.hasReport); - } catch { /* don't fail if stack fetch fails */ } - } - const msg = errors.modal?.message || errors.balloon?.message || 'Unknown 1C error'; - const err = new Error(msg); - err.onecError = { step: name, args, errors, formState: result, stack, screenshot: errorShot }; - throw err; - } - return result; - }; - } - - return ctx; -} - - -async function executeScript(code, { noRecord } = {}) { - const output = []; - const origLog = console.log; - const origErr = console.error; - console.log = (...a) => output.push(a.map(String).join(' ')); - console.error = (...a) => output.push('[ERR] ' + a.map(String).join(' ')); - - const t0 = Date.now(); - try { - const ctx = buildContext({ noRecord }); - - // Normalize Windows backslash paths to prevent JS parse errors - // (e.g. C:\Users\... → \u triggers "Invalid Unicode escape sequence") - code = code.replace(/[A-Za-z]:\\[^\s'"`;\n)}\]]+/g, m => m.replace(/\\/g, '/')); - - const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; - const fn = new AsyncFunction(...Object.keys(ctx), code); - await fn(...Object.values(ctx)); - - console.log = origLog; - console.error = origErr; - return { ok: true, output: output.join('\n'), elapsed: elapsed(t0) }; - } catch (e) { - console.log = origLog; - console.error = origErr; - - // Auto-stop recording if active (prevents "Already recording" on next exec) - if (browser.isRecording()) { - try { await browser.stopRecording(); } catch {} - } - - // Error screenshot (skip if already taken before fetchErrorStack closed the modal) - let shotFile = e.onecError?.screenshot; - if (!shotFile) { - try { - const png = await browser.screenshot(); - shotFile = resolve(__dirname, '..', 'error-shot.png'); - writeFileSync(shotFile, png); - } catch {} - } - - const result = { ok: false, error: e.message, output: output.join('\n'), screenshot: shotFile, elapsed: elapsed(t0) }; - - // Enrich with 1C error context if available - if (e.onecError) { - result.step = e.onecError.step; - result.stepArgs = e.onecError.args; - result.onecErrors = e.onecError.errors; - result.formState = e.onecError.formState; - if (e.onecError.stack) result.stack = e.onecError.stack; - } - - return result; - } -} - - -// ============================================================ -// run: autonomous connect → execute → disconnect (no server) -// ============================================================ - -async function cmdRun(url, fileOrDash) { - if (!url || !fileOrDash) die('Usage: node src/run.mjs run '); - - const code = fileOrDash === '-' - ? await readStdin() - : readFileSync(resolve(fileOrDash), 'utf-8'); - - await browser.connect(url); - const result = await executeScript(code); - await browser.disconnect(); - - out(result); - if (!result.ok) process.exit(1); -} - - -// ============================================================ -// exec: send script to running server -// ============================================================ - -async function cmdExec(fileOrDash, flags = {}) { - if (!fileOrDash) die('Usage: node src/run.mjs exec [--no-record]'); - - let code = fileOrDash === '-' - ? await readStdin() - : readFileSync(resolve(fileOrDash), 'utf-8'); - - const sess = loadSession(); - const headers = {}; - if (flags.noRecord) headers['x-no-record'] = '1'; - const timeoutMs = flags.execTimeoutMs ?? 30 * 60 * 1000; - const result = await new Promise((resolve, reject) => { - const req = http.request({ - hostname: '127.0.0.1', port: sess.port, path: '/exec', - method: 'POST', timeout: timeoutMs, headers, - }, res => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { try { resolve(JSON.parse(data)); } catch { reject(new Error(data)); } }); - }); - req.on('error', reject); - req.on('timeout', () => { req.destroy(new Error(`Exec timeout (${Math.round(timeoutMs / 60000)} min)`)); }); - req.write(code); - req.end(); - }); - out(result); - if (!result.ok) process.exit(1); -} - - -// ============================================================ -// shot: take screenshot via server -// ============================================================ - -async function cmdShot(file) { - const sess = loadSession(); - const resp = await fetch(`http://127.0.0.1:${sess.port}/shot`); - if (!resp.ok) { - const err = await resp.text(); - die(`Screenshot failed: ${err}`); - } - const buf = Buffer.from(await resp.arrayBuffer()); - const outFile = file || 'shot.png'; - writeFileSync(outFile, buf); - out({ ok: true, file: outFile }); -} - - -// ============================================================ -// stop: send stop to server -// ============================================================ - -async function cmdStop() { - const sess = loadSession(); - try { - const resp = await fetch(`http://127.0.0.1:${sess.port}/stop`, { method: 'POST' }); - const result = await resp.json(); - out(result); - } catch { - // Server may have already exited before responding - out({ ok: true, message: 'Stopped' }); - } - cleanup(); -} - - -// ============================================================ -// status: check session -// ============================================================ - -function cmdStatus() { - if (!existsSync(SESSION_FILE)) { - out({ ok: false, message: 'No active session' }); - process.exit(1); - } - const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8')); - out({ ok: true, ...sess }); -} - - -// ============================================================ -// test: run regression tests -// ============================================================ - -async function cmdTest(rawArgs) { - // Split off everything after `--` — those args belong to user-defined hooks - // (see spec §6: "all arguments after `--` are forwarded verbatim to _hooks.mjs - // via the hookArgs field; the runner does not interpret them"). - const sepIdx = rawArgs.indexOf('--'); - const ownArgs = sepIdx >= 0 ? rawArgs.slice(0, sepIdx) : rawArgs; - const hookArgs = sepIdx >= 0 ? rawArgs.slice(sepIdx + 1) : []; - - // Parse flags - const opts = { bail: false, retry: 0, timeout: 30000, report: null, format: 'json', screenshot: null, reportDir: null, record: false }; - let tags = null, grep = null; - const positional = []; - for (const a of ownArgs) { - if (a.startsWith('--tags=')) tags = a.slice(7).split(','); - else if (a.startsWith('--grep=')) grep = new RegExp(a.slice(7), 'i'); - else if (a === '--bail') opts.bail = true; - else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0; - else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000; - else if (a.startsWith('--report=')) opts.report = a.slice(9); - else if (a.startsWith('--format=')) opts.format = a.slice(9); - else if (a.startsWith('--screenshot=')) opts.screenshot = a.slice(13); - else if (a.startsWith('--report-dir=')) opts.reportDir = a.slice(13); - else if (a === '--record') opts.record = true; - else if (!a.startsWith('--')) positional.push(a); - } - - // Determine URL and test path - let url, testPath; - if (positional.length === 2) { - url = positional[0]; - testPath = resolve(positional[1]); - } else if (positional.length === 1) { - testPath = resolve(positional[0]); - } else { - die('Usage: node run.mjs test [url] [--tags=...] [--bail] [--retry=N] [--timeout=ms] [--report=path]'); - } - - // Load config if exists - const isFile = testPath.endsWith('.test.mjs'); - const testDir = isFile ? dirname(testPath) : testPath; - const configPath = resolve(testDir, 'webtest.config.mjs'); - let config = {}; - if (existsSync(configPath)) { - const mod = await import('file:///' + configPath.replace(/\\/g, '/')); - config = mod.default || {}; - } - // Validate severity policy at config load (fail-fast on misconfig). - const severityIndex = buildSeverityIndex(config); - // Build context registry: name → url. Supports config.contexts or single config.url / CLI url. - // CLI url overrides default context's url. - const contextSpecs = {}; // name → { url, isolation } - let defaultContextName = 'default'; - const defaultIsolation = config.isolation || 'tab'; - if (config.contexts && typeof config.contexts === 'object' && Object.keys(config.contexts).length) { - for (const [n, spec] of Object.entries(config.contexts)) { - contextSpecs[n] = { ...spec }; - } - defaultContextName = config.defaultContext || Object.keys(config.contexts)[0]; - if (url) contextSpecs[defaultContextName] = { ...contextSpecs[defaultContextName], url }; // CLI override of default url (preserve custom fields) - } else { - const fallbackUrl = url || config.url; - if (!fallbackUrl) die('No URL provided and no webtest.config.mjs found'); - contextSpecs.default = { url: fallbackUrl }; - } - if (!contextSpecs[defaultContextName]) { - die(`defaultContext "${defaultContextName}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`); - } - if (!url) url = contextSpecs[defaultContextName].url; - - // Apply config defaults (CLI flags override) - if (!tags && config.tags) tags = config.tags; - opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout); - opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry); - // Clipboard preservation: CLI flag wins (already applied at boot), else config can disable. - if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) { - browser.setPreserveClipboard(false); - } - opts.record = opts.record || !!config.record; - opts.screenshot = opts.screenshot || config.screenshot || 'on-failure'; - if (!['on-failure', 'every-step', 'off'].includes(opts.screenshot)) { - die(`Invalid --screenshot=${opts.screenshot} (expected on-failure|every-step|off)`); - } - if (!['json', 'allure', 'junit'].includes(opts.format)) { - die(`Invalid --format=${opts.format} (expected json|allure|junit)`); - } - if (opts.format === 'junit' && !opts.report) { - die('--format=junit requires --report=path.xml'); - } - // Resolve report directory: --report-dir, else dirname(--report), else testDir - const reportDir = opts.reportDir - ? resolve(opts.reportDir) - : (opts.report ? dirname(resolve(opts.report)) : testDir); - if (opts.screenshot !== 'off') { - try { mkdirSync(reportDir, { recursive: true }); } catch {} - } - - // Discover test files - const testFiles = discoverTests(testPath); - if (!testFiles.length) die(`No *.test.mjs files found in ${testPath}`); - - // Import and filter tests - const tests = []; - let hasOnly = false; - for (const file of testFiles) { - const mod = await import('file:///' + file.replace(/\\/g, '/')); - const base = { - file: relative(testDir, file).replace(/\\/g, '/'), - name: mod.name || basename(file, '.test.mjs'), - tags: mod.tags || [], - timeout: mod.timeout || opts.timeout, - skip: mod.skip || false, - only: mod.only || false, - setup: mod.setup, - teardown: mod.teardown, - fn: mod.default, - param: undefined, - context: mod.context || null, - contexts: Array.isArray(mod.contexts) ? mod.contexts : null, - severity: typeof mod.severity === 'string' ? mod.severity : null, - }; - if (base.only) hasOnly = true; - if (Array.isArray(mod.params) && mod.params.length) { - for (let i = 0; i < mod.params.length; i++) { - const p = mod.params[i]; - const name = base.name.includes('{') ? interpolate(base.name, p) : `${base.name}[${i}]`; - tests.push({ ...base, name, param: p }); - } - } else { - tests.push(base); - } - } - - // Filter - const filtered = tests.filter(t => { - if (hasOnly && !t.only) return false; - if (tags && !tags.some(tag => t.tags.includes(tag))) return false; - if (grep && !grep.test(t.name)) return false; - return true; - }); - - // Load hooks - const hooksPath = resolve(testDir, '_hooks.mjs'); - let hooks = {}; - if (existsSync(hooksPath)) { - hooks = await import('file:///' + hooksPath.replace(/\\/g, '/')); - } - - // Console header - const W = process.stderr; - W.write(`\nweb-test -- ${url}\n`); - W.write(`Running ${filtered.length} tests from ${relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'}/\n\n`); - - const startedAt = new Date().toISOString(); - const results = []; - let passCount = 0, failCount = 0, skipCount = 0; - - // Prepare: infrastructure hooks (no browser) - // Spec §6: prepare receives { hookArgs, log, config } — see ExternalDoc. - const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`); - const hookEnv = { hookArgs, log: hookLog, config }; - if (hooks.prepare) await hooks.prepare(hookEnv); - - // Lazy context creation: ensures the named browser context exists, creating it on first request. - // Fires `afterOpenContext(ctx, name, spec)` once per context — right after createContext succeeds. - // The hook receives the same `ctx` that tests use (assembled below), so it can access browser API. - async function ensureContext(name) { - if (browser.hasContext(name)) return; - const spec = contextSpecs[name]; - if (!spec) throw new Error(`Unknown context "${name}". Defined: [${Object.keys(contextSpecs).join(', ')}]`); - await browser.createContext(name, spec.url, { isolation: spec.isolation || defaultIsolation }); - if (hooks.afterOpenContext && hookCtx) { - try { await hooks.afterOpenContext(hookCtx, name, spec); } - catch (e) { hookLog(`afterOpenContext("${name}") threw: ${e.message.split('\n')[0]}`); } - } - } - - // `hookCtx` is set after buildContext below; ensureContext is also called before ctx exists - // (for the default context), so we tolerate `hookCtx === undefined` there — the default - // context's afterOpenContext fires once ctx is built, in the explicit call below. - let hookCtx = null; - - // Wrap `target.closeContext` so calling it from a test fires `beforeCloseContext(ctx, name, spec)` - // before delegating to the bare browser.closeContext. Applied to the flat ctx and each scoped - // context (ctx.a / ctx.b) so `await a.closeContext('b')` triggers the hook. - function wrapCloseContextHook(target) { - const orig = target.closeContext; - if (typeof orig !== 'function') return; - target.closeContext = async (name) => { - if (hooks.beforeCloseContext) { - try { await hooks.beforeCloseContext(target, name, contextSpecs[name]); } - catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); } - } - return await orig(name); - }; - } - - try { - // Connect: create the default context up front (so beforeAll has a working browser) - await ensureContext(defaultContextName); - - // Build context — flat API for single-context tests; reused across tests via setActiveContext. - // noRecord: false → tests get full API (showCaption, startRecording, etc.). The runner manages - // its own recording via --record; if a test author calls startRecording while the runner already - // records, browser.startRecording throws "Already recording" (loud failure beats silent no-op). - const ctx = buildContext({ noRecord: false }); - ctx.assert = createAssertions(); - ctx.log = (...a) => { /* per-test, overridden below */ }; - wrapCloseContextHook(ctx); - hookCtx = ctx; - - // Default context was created BEFORE hookCtx existed → fire afterOpenContext now. - if (hooks.afterOpenContext) { - try { await hooks.afterOpenContext(ctx, defaultContextName, contextSpecs[defaultContextName]); } - catch (e) { hookLog(`afterOpenContext("${defaultContextName}") threw: ${e.message.split('\n')[0]}`); } - } - - // beforeAll - if (hooks.beforeAll) await hooks.beforeAll(ctx); - - // Execute tests - let testIdx = 0; - for (const t of filtered) { - testIdx++; - // Declared contexts — нужны и в skip-ветке, и в основной, чтобы все - // testResult-записи в отчёте всегда содержали `contexts` поле. - const declaredContexts = t.contexts && t.contexts.length - ? t.contexts - : [t.context || defaultContextName]; - - if (t.skip) { - const reason = typeof t.skip === 'string' ? t.skip : ''; - W.write(` \u25CB ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`); - results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null }); - skipCount++; - continue; - } - - // Resolve test's contexts: multi (t.contexts) or single (t.context || default). - // Lazy-create them and set active to the primary one. - const testContextNames = declaredContexts; - try { - for (const cn of testContextNames) await ensureContext(cn); - await browser.setActiveContext(testContextNames[0]); - } catch (e) { - W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`); - results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null }); - failCount++; - if (opts.bail) break; - continue; - } - - let lastError = null; - let testResult = null; - const maxAttempts = 1 + opts.retry; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const output = []; - let steps = []; - let currentSteps = steps; - let stepIdx = 0; - const t0 = Date.now(); - - // testInfo — declarative metadata about the current test, visible - // to test body and hooks (beforeEach/afterEach). Overwritten on - // each attempt and each test (no delete, mirrors ctx.log/step lifecycle). - ctx.testInfo = { - name: t.name, - file: basename(t.file), - filePath: t.file, - tags: t.tags, - timeout: t.timeout, - attempt, - maxAttempts, - param: t.param, - contexts: Object.fromEntries(testContextNames.map(n => [n, contextSpecs[n]])), - primaryContext: testContextNames[0], - }; - ctx.testResult = null; // set right before afterEach - - let videoFile = null; - if (opts.record) { - videoFile = resolve(reportDir, `${testIdx}-${slugify(t.name)}.mp4`); - try { await browser.startRecording(videoFile, { force: true }); } catch { videoFile = null; } - } - - // Wire up per-test log and step - ctx.log = (...a) => output.push(a.map(String).join(' ')); - ctx.step = async (name, fn) => { - const s = { name, start: Date.now(), status: 'passed', steps: [] }; - currentSteps.push(s); - const prev = currentSteps; - currentSteps = s.steps; - stepIdx++; - const myIdx = stepIdx; - try { - await fn(); - } catch (e) { - s.status = 'failed'; - s.error = e.message; - throw e; - } finally { - s.stop = Date.now(); - currentSteps = prev; - if (opts.screenshot === 'every-step' && s.status === 'passed') { - try { - const slug = slugify(name); - const file = resolve(reportDir, `${testIdx}-${myIdx}-${slug}.png`); - const png = await browser.screenshot(); - writeFileSync(file, png); - s.screenshot = file; - } catch {} - } - } - }; - - // For multi-context tests, expose ctx. per-context wrappers - const scopedKeys = []; - if (t.contexts && t.contexts.length) { - for (const cn of t.contexts) { - ctx[cn] = buildScopedContext(cn); - wrapCloseContextHook(ctx[cn]); - scopedKeys.push(cn); - } - } - - try { - // beforeEach - if (hooks.beforeEach) await hooks.beforeEach(ctx); - // per-test setup - if (t.setup) await t.setup(ctx); - - // Run test with timeout - await Promise.race([ - t.fn(ctx, t.param), - new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout (${t.timeout}ms)`)), t.timeout)), - ]); - - // per-test teardown - if (t.teardown) try { await t.teardown(ctx); } catch {} - // Expose testResult to afterEach (preliminary — full testResult assembled below). - ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps }; - // afterEach - if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {} - // Built-in state reset across all contexts the test used - for (const cn of testContextNames) { - try { await browser.setActiveContext(cn); await resetState(ctx); } catch {} - } - for (const k of scopedKeys) delete ctx[k]; - - if (videoFile) { - try { await browser.stopRecording(); } catch {} - } - const dur = elapsed(t0); - testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile }; - lastError = null; - break; - - } catch (e) { - // Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI. - // Otherwise the shot captures an empty desktop instead of the failure context. - let shotFile = e.onecError?.screenshot; - if (!shotFile && opts.screenshot !== 'off') { - try { - const png = await browser.screenshot(); - shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`); - writeFileSync(shotFile, png); - } catch {} - } - - // per-test teardown (always) - if (t.teardown) try { await t.teardown(ctx); } catch {} - // Build the error record once: shared between ctx.testResult (afterEach), lastError - // (retry-loop carry-over and console output), and the final report record. - // onecError carries the structured 1C exception payload (stack, formState, args, ...) - // produced by ACTION_FN wrappers — preserve it in the report. - const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError }; - ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps }; - // afterEach (always) - if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {} - // Built-in state reset across all contexts the test used - for (const cn of testContextNames) { - try { await browser.setActiveContext(cn); await resetState(ctx); } catch {} - } - for (const k of scopedKeys) delete ctx[k]; - - if (videoFile) { - try { await browser.stopRecording(); } catch {} - } - lastError = errInfo; - const dur = elapsed(t0); - testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: errInfo, screenshot: shotFile, video: videoFile }; - } - } - - results.push(testResult); - - // Console output - if (testResult.status === 'passed') { - passCount++; - W.write(` \u2713 ${t.name} (${testResult.duration}s)\n`); - } else { - failCount++; - W.write(` \u2717 ${t.name} (${testResult.duration}s)\n`); - // Show failed steps - printSteps(W, testResult.steps, ' '); - if (lastError?.message) W.write(` ${lastError.message}\n`); - if (lastError?.screenshot) W.write(` screenshot: ${lastError.screenshot}\n`); - } - - if (opts.bail && testResult.status === 'failed') break; - } - - // afterAll - if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {} - - } finally { - // Per-context teardown: fire beforeCloseContext for every remaining slot, then close. - // Mirror the `ctx.a.closeContext('b')` invariant: active is some OTHER context while - // closing `name`. We keep the first registered context (the default) as the survivor — - // it stays active, hooks fire against it, the other slots are closed one by one. - // The default itself is closed by disconnect() (no surviving context to switch to). - try { - const remaining = browser.listContexts(); - if (remaining.length > 0) { - const survivor = remaining[0]; - try { await browser.setActiveContext(survivor); } catch {} - for (let i = remaining.length - 1; i >= 1; i--) { - const name = remaining[i]; - if (hooks.beforeCloseContext && hookCtx) { - try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); } - catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); } - } - try { await browser.closeContext(name); } - catch (e) { hookLog(`closeContext("${name}") failed: ${e.message.split('\n')[0]}`); } - } - // Fire beforeCloseContext for the survivor too — disconnect() actually closes it. - if (hooks.beforeCloseContext && hookCtx) { - try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); } - catch (e) { hookLog(`beforeCloseContext("${survivor}") threw: ${e.message.split('\n')[0]}`); } - } - } - } catch (e) { - hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`); - } - // Disconnect — closes the last remaining context + browser. - try { await browser.disconnect(); } catch {} - // Cleanup: infrastructure hooks (same signature as prepare) - if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {} - } - - const finishedAt = new Date().toISOString(); - const totalDuration = results.reduce((s, r) => s + r.duration, 0); - - // Summary - W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`); - - // JSON report - const report = { - runner: 'web-test', url, startedAt, finishedAt, - duration: totalDuration, - summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount }, - tests: results, - }; - out(report); - - if (opts.format === 'allure') { - writeAllure(results, reportDir, severityIndex); - syncAllureExtras(testDir, reportDir); - } else if (opts.format === 'junit') { - writeFileSync(resolve(opts.report), buildJUnit(report, testDir)); - } else if (opts.report) { - writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2)); - } - - if (failCount > 0) process.exit(1); -} - -/** - * Copy any files from `/_allure/` into `reportDir`. Convention for - * Allure customization that doesn't fit inside per-test JSON: - * - `categories.json` — failure classification (regex → bucket) - * - `environment.properties` — values shown in the Environment widget - * - `executor.json` — CI/CD metadata - * Underscored folder mirrors `_hooks.mjs` convention (infra, not a test). - * Silent if folder absent. - */ -function syncAllureExtras(testDir, reportDir) { - const extrasDir = resolve(testDir, '_allure'); - if (!existsSync(extrasDir)) return; - try { - if (!statSync(extrasDir).isDirectory()) return; - } catch { return; } - for (const entry of readdirSync(extrasDir, { withFileTypes: true })) { - if (!entry.isFile()) continue; - try { copyFileSync(resolve(extrasDir, entry.name), resolve(reportDir, entry.name)); } - catch { /* best-effort */ } - } -} - -function writeAllure(results, reportDir, severityIndex) { - for (const tr of results) { - if (tr.status === 'skipped') continue; // Allure ignores skipped without start/stop - const uuid = randomUUID(); - // suite: dirname(t.file) даёт автогруппировку отчёта по подкаталогам. - // Плоский слой тестов в корне группируется под 'root'. - const suite = dirname(tr.file); - const suiteLabel = (suite && suite !== '.') ? suite : 'root'; - const severity = resolveSeverity(tr, severityIndex); - const out = { - uuid, - name: tr.name, - fullName: tr.file, - status: tr.status, - stage: 'finished', - start: tr.start, - stop: tr.stop, - labels: [ - ...(tr.tags || []).map(t => ({ name: 'tag', value: t })), - { name: 'suite', value: suiteLabel }, - { name: 'severity', value: severity }, - ], - steps: (tr.steps || []).map(allureStep), - attachments: [ - ...(tr.screenshot ? [{ name: 'Screenshot on failure', source: basename(tr.screenshot), type: 'image/png' }] : []), - ...(tr.video ? [{ name: 'Video', source: basename(tr.video), type: 'video/mp4' }] : []), - ], - }; - if (tr.status === 'failed' && tr.error) { - // Allure UI shows statusDetails.trace right next to the error message. We compose it - // from the test's log() output plus, when present, the platform 1C stack — so the - // raw call chain is visible without opening attachments. - const traceParts = []; - if (tr.output) traceParts.push(tr.output); - const onecStack = tr.error.onecError?.stack?.raw; - if (onecStack) { - if (traceParts.length) traceParts.push('\n--- 1C stack ---\n'); - traceParts.push(onecStack); - } - out.statusDetails = { message: tr.error.message || '', trace: traceParts.join('') }; - } - writeFileSync(resolve(reportDir, `${uuid}-result.json`), JSON.stringify(out, null, 2)); - } -} - -function allureStep(s) { - const out = { - name: s.name, - status: s.status, - stage: 'finished', - start: s.start, - stop: s.stop, - steps: (s.steps || []).map(allureStep), - }; - if (s.screenshot) { - out.attachments = [{ name: 'Screenshot', source: basename(s.screenshot), type: 'image/png' }]; - } - if (s.status === 'failed' && s.error) { - out.statusDetails = { message: s.error, trace: s.error }; - } - return out; -} - -function xmlEscape(s) { - return String(s == null ? '' : s) - .replace(/&/g, '&').replace(//g, '>') - .replace(/"/g, '"').replace(/'/g, '''); -} - -function buildJUnit(report, testDir) { - const { summary, duration, tests } = report; - const suiteName = relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'; - const lines = ['']; - lines.push(``); - lines.push(` `); - for (const t of tests) { - const attrs = `name="${xmlEscape(t.name)}" classname="${xmlEscape(t.file)}" time="${(t.duration || 0).toFixed(3)}"`; - if (t.status === 'passed') { - lines.push(` `); - } else if (t.status === 'skipped') { - lines.push(` `); - } else { - lines.push(` `); - const msg = t.error?.message || ''; - const trace = t.output || ''; - lines.push(` ${xmlEscape(trace)}`); - if (t.screenshot) lines.push(` screenshot: ${xmlEscape(t.screenshot)}`); - lines.push(` `); - } - } - lines.push(` `); - lines.push(``); - return lines.join('\n'); -} - -function discoverTests(testPath) { - if (testPath.endsWith('.test.mjs')) return existsSync(testPath) ? [testPath] : []; - const files = []; - function walk(dir) { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue; - const full = resolve(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith('.test.mjs')) files.push(full); - } - } - walk(testPath); - return files.sort(); -} - -async function resetState(ctx) { - try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {} - for (let i = 0; i < 10; i++) { - try { - const state = await ctx.getFormState(); - // form === null means no form open (desktop). form === 0 is a real background form - // 1C exposes in some states — must still close it to fully reset. - if (state.form == null) break; - await ctx.closeForm({ save: false }); - } catch { break; } - } -} - -function printSteps(W, steps, indent) { - for (let i = 0; i < steps.length; i++) { - const s = steps[i]; - const last = i === steps.length - 1; - const prefix = last ? '\u2514' : '\u251C'; - const mark = s.status === 'failed' ? '\u2717 ' : ''; - W.write(`${indent}${prefix} ${mark}${s.name} (${elapsed2(s.start, s.stop)}s)\n`); - if (s.error && s.status === 'failed') { - W.write(`${indent} ${s.error}\n`); - } - if (s.steps.length) printSteps(W, s.steps, indent + ' '); - } -} - -function elapsed2(start, stop) { - return Math.round(((stop || Date.now()) - start) / 100) / 10; -} - -function interpolate(template, params) { - return String(template).replace(/\{(\w+)\}/g, (_, key) => - params[key] !== undefined ? String(params[key]) : `{${key}}`); -} - -function slugify(s) { - return String(s).trim() - .replace(/[\s/\\:*?"<>|]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 60) || 'step'; -} - -function formatDuration(seconds) { - if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`; - const m = Math.floor(seconds / 60); - const s = Math.round((seconds - m * 60) * 10) / 10; - return `${m}m ${s}s`; -} - -// ============================================================ -// Severity (Allure label policy) — constants live at module top. -// ============================================================ - -/** - * Validate config.severity (inverted map: severity → [tags]) at config load time. - * Returns: - * - tagToSeverity: Map (precomputed lookup for the resolver) - * - defaultSeverity: string (validated, defaults to 'normal') - * Throws (via die) on invalid keys, invalid default, or duplicate tag across buckets. - */ -function buildSeverityIndex(config) { - const tagToSeverity = new Map(); - const sev = config.severity || {}; - if (typeof sev !== 'object' || Array.isArray(sev)) { - die(`config.severity must be an object, got ${typeof sev}`); - } - for (const [level, tags] of Object.entries(sev)) { - if (!SEVERITY_LEVELS.includes(level)) { - die(`config.severity: unknown level "${level}". Allowed: ${SEVERITY_LEVELS.join('|')}`); - } - if (!Array.isArray(tags)) { - die(`config.severity.${level} must be an array of tag names, got ${typeof tags}`); - } - for (const tag of tags) { - if (tagToSeverity.has(tag)) { - die(`config.severity: tag "${tag}" listed under both "${tagToSeverity.get(tag)}" and "${level}" — pick one`); - } - tagToSeverity.set(tag, level); - } - } - const def = config.defaultSeverity || 'normal'; - if (!SEVERITY_LEVELS.includes(def)) { - die(`config.defaultSeverity: "${def}" is not a valid level. Allowed: ${SEVERITY_LEVELS.join('|')}`); - } - return { tagToSeverity, defaultSeverity: def }; -} - -/** - * Resolve a test's severity. Precedence: - * 1. explicit `export const severity` from the test module - * 2. max-rank severity found among tags (either standard severity name, or mapped via config) - * 3. defaultSeverity from config (or 'normal' if not set) - * Returns one of SEVERITY_LEVELS. - */ -function resolveSeverity(t, severityIndex) { - if (t.severity) { - if (!SEVERITY_LEVELS.includes(t.severity)) { - // Не валим тест — просто игнорируем некорректное значение, дефолтим. - return severityIndex.defaultSeverity; - } - return t.severity; - } - let best = null; - for (const tag of t.tags || []) { - let candidate = null; - if (SEVERITY_LEVELS.includes(tag)) candidate = tag; - else if (severityIndex.tagToSeverity.has(tag)) candidate = severityIndex.tagToSeverity.get(tag); - if (candidate && (best === null || SEVERITY_RANK[candidate] > SEVERITY_RANK[best])) { - best = candidate; - } - } - return best || severityIndex.defaultSeverity; -} - - -// ============================================================ -// assertions -// ============================================================ - -function createAssertions() { - class AssertionError extends Error { - constructor(msg, actual, expected) { - super(msg); - this.name = 'AssertionError'; - this.actual = actual; - this.expected = expected; - } - } - - return { - ok(value, msg) { - if (!value) throw new AssertionError(msg || `Expected truthy, got ${JSON.stringify(value)}`, value, true); - }, - equal(actual, expected, msg) { - if (actual !== expected) throw new AssertionError(msg || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, actual, expected); - }, - notEqual(actual, expected, msg) { - if (actual === expected) throw new AssertionError(msg || `Expected not ${JSON.stringify(expected)}`, actual, expected); - }, - deepEqual(actual, expected, msg) { - const a = JSON.stringify(actual), b = JSON.stringify(expected); - if (a !== b) throw new AssertionError(msg || `Deep equal failed:\n actual: ${a}\n expected: ${b}`, actual, expected); - }, - includes(haystack, needle, msg) { - const h = Array.isArray(haystack) ? haystack : String(haystack); - if (!h.includes(needle)) throw new AssertionError(msg || `Expected ${JSON.stringify(h)} to include ${JSON.stringify(needle)}`, haystack, needle); - }, - match(string, regex, msg) { - if (!regex.test(string)) throw new AssertionError(msg || `Expected ${JSON.stringify(string)} to match ${regex}`, string, regex); - }, - async throws(fn, msg) { - try { await fn(); } catch { return; } - throw new AssertionError(msg || 'Expected function to throw'); - }, - // 1C-specific - formHasField(state, fieldName, msg) { - if (!state?.fields?.[fieldName]) throw new AssertionError(msg || `Field "${fieldName}" not found in form. Available: ${Object.keys(state?.fields || {}).join(', ')}`, null, fieldName); - }, - formTitle(state, expected, msg) { - if (!state?.title?.includes(expected)) throw new AssertionError(msg || `Form title "${state?.title}" does not contain "${expected}"`, state?.title, expected); - }, - tableHasRow(table, predicate, msg) { - const rows = table?.rows || []; - let found; - if (typeof predicate === 'function') { - found = rows.some(predicate); - } else { - found = rows.some(r => Object.entries(predicate).every(([k, v]) => r[k] === v)); - } - if (!found) throw new AssertionError(msg || `No row matching predicate in table (${rows.length} rows)`, null, predicate); - }, - tableRowCount(table, expected, msg) { - const actual = table?.rows?.length ?? 0; - if (actual !== expected) throw new AssertionError(msg || `Expected ${expected} rows, got ${actual}`, actual, expected); - }, - noErrors(state, msg) { - if (state?.errors) throw new AssertionError(msg || `Form has errors: ${JSON.stringify(state.errors)}`, state.errors, null); - }, - }; -} - - -// ============================================================ -// helpers -// ============================================================ - -function loadSession() { - if (!existsSync(SESSION_FILE)) { - die('No active session. Run: node src/run.mjs start '); - } - return JSON.parse(readFileSync(SESSION_FILE, 'utf-8')); -} - -function cleanup() { - try { unlinkSync(SESSION_FILE); } catch {} -} - -async function readBody(req) { - const chunks = []; - for await (const chunk of req) chunks.push(chunk); - return Buffer.concat(chunks).toString('utf-8'); -} - -async function readStdin() { - const chunks = []; - for await (const chunk of process.stdin) chunks.push(chunk); - return Buffer.concat(chunks).toString('utf-8'); -} - -function elapsed(t0) { - return Math.round((Date.now() - t0) / 100) / 10; -} - -function json(res, obj, status = 200) { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(obj, null, 2)); -} - -function out(obj) { - process.stdout.write(JSON.stringify(obj, null, 2) + '\n'); -} - -function die(msg) { - process.stderr.write(msg + '\n'); - process.exit(1); -} - -function usage() { - die(`Usage: node run.mjs [args] - -Commands: - start Launch browser and connect to 1C web client - run Autonomous: connect, execute script, disconnect - exec [options] Execute script (file path or - for stdin) - shot [file] Take screenshot (default: shot.png) - stop Logout and close browser - status Check session status - test [url] Run regression tests (*.test.mjs) - -Options for exec: - --no-record Skip video recording (record() becomes no-op) - -Global options (any command): - --no-preserve-clipboard Don't save/restore OS clipboard around action calls. - Default: on (env: WEB_TEST_PRESERVE_CLIPBOARD=0 to disable globally). - -Options for test: - --tags=smoke,crud Filter tests by tags - --grep=pattern Filter tests by name (regex) - --bail Stop on first failure - --retry=N Retry failed tests N times - --timeout=ms Per-test timeout (default: 30000) - --report=path Write JSON report to file - --report-dir=path Directory for screenshots and other artifacts - --screenshot=mode on-failure (default) | every-step | off - --format=fmt json (default) | allure | junit - --record Record video for each test (mp4 in report-dir) - -- Everything after \`--\` is forwarded to _hooks.mjs - prepare/cleanup as hookArgs (runner does not parse it). - Example: ... tests/web-test/ -- --rebuild-stand`); -} +#!/usr/bin/env node +// web-test run v1.17 — CLI entry-point (распилено по cli/) +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +/** + * CLI runner for 1C web client automation. + * + * Architecture: `start` launches browser + HTTP server in one process. + * `exec`, `shot`, `stop` send requests to the running server. + * + * Usage: + * node src/run.mjs start — launch browser, connect to 1C, serve requests + * node src/run.mjs run — autonomous: connect, execute script, disconnect + * node src/run.mjs exec — run script against existing session + * node src/run.mjs shot [file] — take screenshot + * node src/run.mjs stop — logout + close browser + * node src/run.mjs status — check session + * node src/run.mjs test [url] — run regression tests + * + * Внутренности живут в cli/: util, session, exec-context, server, + * commands/{start,run,exec,shot,stop,status,test}, test-runner/*. + */ +import * as browser from './browser.mjs'; +import { usage } from './cli/util.mjs'; +import { cmdStart } from './cli/commands/start.mjs'; +import { cmdRun } from './cli/commands/run.mjs'; +import { cmdExec } from './cli/commands/exec.mjs'; +import { cmdShot } from './cli/commands/shot.mjs'; +import { cmdStop } from './cli/commands/stop.mjs'; +import { cmdStatus } from './cli/commands/status.mjs'; +import { cmdTest } from './cli/commands/test.mjs'; + +const [,, cmd, ...rawArgs] = process.argv; +const flags = { + noRecord: rawArgs.includes('--no-record'), + execTimeoutMs: parseExecTimeoutMs(rawArgs), +}; +const args = rawArgs.filter(a => !a.startsWith('--')); + +// Clipboard preservation: default ON. Disabled by --no-preserve-clipboard CLI flag +// or WEB_TEST_PRESERVE_CLIPBOARD=0 env. cmdTest may further disable via config. +const preserveClipboard = !rawArgs.includes('--no-preserve-clipboard') + && process.env.WEB_TEST_PRESERVE_CLIPBOARD !== '0'; +browser.setPreserveClipboard(preserveClipboard); + +function parseExecTimeoutMs(argv) { + const DEFAULT_MS = 30 * 60 * 1000; + const flagMs = argv.find(a => a.startsWith('--timeout=')); + if (flagMs) return Math.max(1, Number(flagMs.slice('--timeout='.length))) || DEFAULT_MS; + const flagMin = argv.find(a => a.startsWith('--timeout-min=')); + if (flagMin) return Math.max(1, Number(flagMin.slice('--timeout-min='.length))) * 60 * 1000 || DEFAULT_MS; + const env = process.env.WEB_TEST_EXEC_TIMEOUT_MS; + if (env) return Math.max(1, Number(env)) || DEFAULT_MS; + return DEFAULT_MS; +} + +switch (cmd) { + case 'start': await cmdStart(args[0]); break; + case 'run': await cmdRun(args[0], args[1]); break; + case 'exec': await cmdExec(args[0], flags); break; + case 'shot': await cmdShot(args[0]); break; + case 'stop': await cmdStop(); break; + case 'status': cmdStatus(); break; + case 'test': await cmdTest(rawArgs); break; + default: usage(); +}