mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-12 00:44:57 +03:00
feat(web-test): test runner — buildContext, cmdTest, assertions, step
- Извлечён buildContext() из executeScript (переиспользуется) - Новая команда `test [url] <dir> [--tags/--bail/--retry/--timeout/--report]` - Обнаружение *.test.mjs, импорт ES-модулей, фильтрация по тегам/grep/only - Хуки: prepare/cleanup (без браузера) + beforeAll/afterAll/beforeEach/afterEach - Встроенный сброс состояния (dismissPendingErrors + closeForm) после каждого теста - step(name, fn) обёртка с вложенностью и таймингами - Assertions: ok/equal/deepEqual/includes/match/throws + 1C-специфичные - Консольный вывод с деревом шагов, JSON-отчёт - Поддержка webtest.config.mjs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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] <dir|file> — 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] <dir|file> [--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 <command> [args]
|
||||
die(`Usage: node run.mjs <command> [args]
|
||||
|
||||
Commands:
|
||||
start <url> 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] <dir|file> 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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user