diff --git a/.claude/skills/web-test/scripts/run.mjs b/.claude/skills/web-test/scripts/run.mjs
index 1566b7a4..6611dc54 100644
--- a/.claude/skills/web-test/scripts/run.mjs
+++ b/.claude/skills/web-test/scripts/run.mjs
@@ -1,5 +1,5 @@
#!/usr/bin/env node
-// web-test run v1.3 — CLI runner for 1C web client automation
+// web-test run v1.4 — CLI runner for 1C web client automation
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* CLI runner for 1C web client automation.
@@ -14,11 +14,12 @@
* 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 } from 'fs';
-import { resolve, dirname } from 'path';
+import { readFileSync, writeFileSync, unlinkSync, existsSync, readdirSync } from 'fs';
+import { resolve, dirname, basename, relative } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -35,6 +36,7 @@ switch (cmd) {
case 'shot': await cmdShot(args[0]); break;
case 'stop': await cmdStop(); break;
case 'status': cmdStatus(); break;
+ case 'test': await cmdTest(rawArgs); break;
default: usage();
}
@@ -101,6 +103,72 @@ async function handleRequest(req, res) {
}
}
+// ============================================================
+// buildContext: assemble browser API with error wrapping
+// ============================================================
+
+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;
@@ -110,71 +178,15 @@ async function executeScript(code, { noRecord } = {}) {
const t0 = Date.now();
try {
- // Build sandbox: all browser.mjs exports + useful Node globals
- const exports = {};
- for (const [k, v] of Object.entries(browser)) {
- if (k !== 'default') exports[k] = v;
- }
- exports.writeFileSync = writeFileSync;
- exports.readFileSync = readFileSync;
-
- // --no-record: stub recording/narration functions to return safe defaults
- if (noRecord) {
- const noop = async () => {};
- exports.startRecording = noop;
- exports.stopRecording = async () => ({ file: null, duration: 0, size: 0 });
- exports.addNarration = async () => ({ file: null, duration: 0, size: 0, captions: 0 });
- for (const fn of ['showCaption', 'hideCaption']) {
- exports[fn] = noop;
- }
- exports.isRecording = () => false;
- exports.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 exports[name] !== 'function') continue;
- const orig = exports[name];
- exports[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 exports.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 exports.fetchErrorStack === 'function') {
- try {
- stack = await exports.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;
- };
- }
+ 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(exports), code);
- await fn(...Object.values(exports));
+ const fn = new AsyncFunction(...Object.keys(ctx), code);
+ await fn(...Object.values(ctx));
console.log = origLog;
console.error = origErr;
@@ -317,6 +329,375 @@ function cmdStatus() {
}
+// ============================================================
+// test: run regression tests
+// ============================================================
+
+async function cmdTest(rawArgs) {
+ // Parse flags
+ const opts = { bail: false, retry: 0, timeout: 30000, report: null, format: 'json' };
+ let tags = null, grep = null;
+ const positional = [];
+ for (const a of rawArgs) {
+ 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('--')) 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 testDir = existsSync(testPath) && readdirSync(testPath, { withFileTypes: true }).length >= 0
+ ? testPath : dirname(testPath);
+ const configPath = resolve(testDir, 'webtest.config.mjs');
+ let config = {};
+ if (existsSync(configPath)) {
+ const mod = await import('file:///' + configPath.replace(/\\/g, '/'));
+ config = mod.default || {};
+ }
+ if (!url) {
+ url = config.url || config.contexts?.[config.defaultContext || Object.keys(config.contexts || {})[0]]?.url;
+ }
+ if (!url) die('No URL provided and no webtest.config.mjs found');
+
+ // Apply config defaults (CLI flags override)
+ if (!tags && config.tags) tags = config.tags;
+ opts.timeout = rawArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout);
+ opts.retry = rawArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry);
+
+ // 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 t = {
+ 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,
+ };
+ if (t.only) hasOnly = true;
+ tests.push(t);
+ }
+
+ // 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)
+ if (hooks.prepare) await hooks.prepare();
+
+ try {
+ // Connect
+ await browser.connect(url);
+
+ // Build context
+ const ctx = buildContext({ noRecord: true });
+ ctx.assert = createAssertions();
+ ctx.log = (...a) => { /* per-test, overridden below */ };
+
+ // beforeAll
+ if (hooks.beforeAll) await hooks.beforeAll(ctx);
+
+ // Execute tests
+ for (const t of filtered) {
+ 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, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null });
+ skipCount++;
+ 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;
+ const t0 = Date.now();
+
+ // 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;
+ try {
+ await fn();
+ } catch (e) {
+ s.status = 'failed';
+ s.error = e.message;
+ throw e;
+ } finally {
+ s.stop = Date.now();
+ currentSteps = prev;
+ }
+ };
+
+ 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),
+ 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 {}
+ // afterEach
+ if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
+ // Built-in state reset
+ await resetState(ctx);
+
+ const dur = elapsed(t0);
+ testResult = { name: t.name, file: t.file, tags: t.tags, status: 'passed', duration: dur, attempts: attempt, steps, output: output.join('\n'), error: null, screenshot: null };
+ lastError = null;
+ break;
+
+ } catch (e) {
+ // per-test teardown (always)
+ if (t.teardown) try { await t.teardown(ctx); } catch {}
+ // afterEach (always)
+ if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
+ // Built-in state reset
+ await resetState(ctx);
+
+ // Screenshot on failure
+ let shotFile = e.onecError?.screenshot;
+ if (!shotFile) {
+ try {
+ const png = await browser.screenshot();
+ shotFile = resolve(__dirname, '..', `error-shot-${t.file.replace(/[/\\]/g, '-')}.png`);
+ writeFileSync(shotFile, png);
+ } catch {}
+ }
+
+ lastError = { message: e.message, step: e.onecError?.step, screenshot: shotFile };
+ const dur = elapsed(t0);
+ testResult = { name: t.name, file: t.file, tags: t.tags, status: 'failed', duration: dur, attempts: attempt, steps, output: output.join('\n'), error: lastError, screenshot: shotFile };
+ }
+ }
+
+ 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 {
+ // Disconnect
+ try { await browser.disconnect(); } catch {}
+ // Cleanup: infrastructure hooks
+ if (hooks.cleanup) try { await hooks.cleanup(); } 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.report) {
+ writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2));
+ }
+
+ if (failCount > 0) process.exit(1);
+}
+
+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();
+ if (!state.form) 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 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`;
+}
+
+
+// ============================================================
+// 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
// ============================================================
@@ -363,7 +744,7 @@ function die(msg) {
}
function usage() {
- die(`Usage: node src/run.mjs [args]
+ die(`Usage: node run.mjs [args]
Commands:
start Launch browser and connect to 1C web client
@@ -372,7 +753,16 @@ Commands:
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)`);
+ --no-record Skip video recording (record() becomes no-op)
+
+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`);
}