Auto-build: windsurf (python) from 37f39a5

This commit is contained in:
github-actions[bot]
2026-07-17 17:43:12 +00:00
commit d4d3d60e5f
303 changed files with 146092 additions and 0 deletions
@@ -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 <file|-> [--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);
}
@@ -0,0 +1,29 @@
// web-test cli/commands/run v1.1 — 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 <url> <file|->');
const code = fileOrDash === '-'
? await readStdin()
: readFileSync(resolve(fileOrDash), 'utf-8');
// Same as cmdStart: a startup blocker is a diagnosis, not a crash. connect() has already
// released the seance and closed the browser; a stack trace pointing into session.mjs would
// read as an engine bug and send the reader off to debug the wrong thing.
try {
await browser.connect(url);
} catch (e) {
die(e.message);
}
const result = await executeScript(code);
await browser.disconnect();
out(result);
if (!result.ok) process.exit(1);
}
@@ -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 });
}
@@ -0,0 +1,41 @@
// web-test cli/commands/start v1.1
// 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 <url>');
// A startup blocker (no free 1C licence, publication asking for credentials) is a diagnosis,
// not a crash — connect() already released the seance and closed the browser, so print the
// message and leave instead of dumping a stack trace that reads like an engine failure.
let state;
try {
state = await browser.connect(url);
} catch (e) {
die(e.message);
}
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);
});
}
@@ -0,0 +1,32 @@
// web-test cli/commands/status v1.1 — check session (active liveness probe)
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { existsSync, readFileSync } from 'fs';
import { out } from '../util.mjs';
import { SESSION_FILE, cleanup } from '../session.mjs';
export async function cmdStatus() {
if (!existsSync(SESSION_FILE)) {
out({ ok: false, ready: false, message: 'No active session' });
process.exit(1);
}
const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
// The session file is written only after connect() finished, but the server process may have
// died since (crash/reboot) and left the file behind. Don't trust the file — probe the
// in-process /status endpoint for real liveness.
try {
const resp = await fetch(`http://127.0.0.1:${sess.port}/status`, { signal: AbortSignal.timeout(2000) });
const body = await resp.json();
if (body.connected) {
out({ ok: true, ready: true, ...sess });
} else {
out({ ok: false, ready: false, reason: 'browser-disconnected', ...sess });
process.exit(1);
}
} catch {
// Server unreachable → the file is stale (process gone). Self-heal by removing it so the
// next status/start reads clean.
cleanup();
out({ ok: false, ready: false, reason: 'server-unreachable', ...sess });
process.exit(1);
}
}
@@ -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();
}
@@ -0,0 +1,832 @@
// web-test cli/commands/test v1.8 — regression test runner
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { existsSync, writeFileSync, mkdirSync, renameSync, copyFileSync, unlinkSync } from 'fs';
import { resolve, dirname, basename, relative } from 'path';
import * as browser from '../../browser.mjs';
import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps, softDeadline } from '../util.mjs';
import { buildContext, buildScopedContext, setErrorShotDir } 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';
import { planEviction, touchLru, dropLru } from '../test-runner/context-pool.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) : [];
// Deadline budgets for the cleanup path. Every one of these calls reaches into Playwright
// and can hang forever against a wedged renderer (page.evaluate has no timeout of its own),
// so none of them may be awaited bare. A breach is always logged — silent swallowing is what
// turned the original incident into a 29-minute mystery.
//
// These defaults are sized for a light stand. A heavy application legitimately needs more —
// override per key via `deadlines: {...}` in webtest.config.mjs rather than editing this.
const D = {
screenshot: 10000,
teardown: 15000,
afterEach: 15000,
setActive: 5000,
resetState: 20000,
startRecording: 15000,
stopRecording: 40000, // ffmpeg has its own 30s inside
closeContext: 20000,
disconnect: 30000,
hooks: 120000, // afterAll/cleanup only — prepare/beforeAll stay unbounded (see below)
abortAll: 30000, // whole abort+cleanup sequence for one hung test
probe: 2000,
};
// Parse flags
const opts = { bail: false, retry: 0, timeout: 30000, globalTimeout: 0, report: null, format: 'json', screenshot: null, reportDir: null, record: false };
let tags = null, grep = null, urlFlag = 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.startsWith('--url=')) urlFlag = a.slice(6);
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('--global-timeout=')) opts.globalTimeout = parseInt(a.slice(17)) || 0;
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);
}
// Positional args are ALWAYS test paths (one or many). URL comes from --url= or config
// (see webtest.config.mjs). This matches pytest/jest/playwright; a positional that looks
// like a URL is a mistake → fail fast with a hint instead of feeding it to page.goto().
const isUrl = (s) => /^https?:\/\//i.test(s);
let url = urlFlag || null;
const testPaths = [...positional];
if (testPaths.length === 0) {
die('Usage: node run.mjs test <dir|file>... [--url=URL] [--tags=...] [--grep=...] [--bail] [--retry=N] [--timeout=ms] [--report=path]');
}
for (const p of testPaths) {
if (existsSync(resolve(p))) continue;
if (isUrl(p)) {
die(`"${p}" looks like a URL — use --url=<url>; positional args are test paths.`);
}
die(`Test path not found: "${p}". To run a subset use --grep= / --tags=, or pass an existing dir/file.`);
}
// Load config if exists. config (webtest.config.mjs) and hooks (_hooks.mjs) resolve from
// the FIRST path's directory — list paths from the same suite folder.
const firstPath = resolve(testPaths[0]);
const isFile = firstPath.endsWith('.test.mjs');
const testDir = isFile ? dirname(firstPath) : firstPath;
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;
// Context-pool config (license management). All three optional; without them the runner keeps
// its legacy behavior: default stays open, contexts accumulate, no eviction.
// maxContexts — cap on simultaneous 1C sessions (null = unlimited).
// contextPolicy — 'reuse' (keep open within the cap) | 'strict' (close a test's non-pinned
// contexts right after it, to release licenses ASAP).
// pinnedContexts — never evicted by LRU. Defaults to [defaultContext] so today's "default is
// never closed between tests" holds; set [] to make default evictable.
let maxContexts = null;
if (config.maxContexts != null) {
if (!Number.isInteger(config.maxContexts) || config.maxContexts < 1) {
die(`Invalid maxContexts=${config.maxContexts} (expected a positive integer or omit for unlimited)`);
}
maxContexts = config.maxContexts;
}
const contextPolicy = config.contextPolicy == null ? 'reuse' : config.contextPolicy;
if (!['reuse', 'strict'].includes(contextPolicy)) {
die(`Invalid contextPolicy="${contextPolicy}" (expected 'reuse' or 'strict')`);
}
const pinnedContexts = Array.isArray(config.pinnedContexts) ? config.pinnedContexts : [defaultContextName];
for (const n of pinnedContexts) {
if (!contextSpecs[n]) die(`pinnedContexts entry "${n}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`);
}
const pinnedSet = new Set(pinnedContexts);
// LRU usage order — oldest first, freshest last. Drives eviction under a maxContexts cap.
const lruOrder = [];
// 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);
opts.globalTimeout = ownArgs.some(a => a.startsWith('--global-timeout=')) ? opts.globalTimeout : (config.globalTimeout || opts.globalTimeout);
// Per-key deadline overrides. Defaults suit a light stand; a heavy application may honestly
// need longer (a big form's resetState, a slow close). Unknown keys are a typo, not a wish —
// fail fast rather than silently ignoring an override the author believed was in effect.
if (config.deadlines) {
for (const [k, v] of Object.entries(config.deadlines)) {
if (!(k in D)) die(`Invalid deadlines.${k} in config (expected one of: ${Object.keys(D).join(', ')})`);
if (typeof v !== 'number' || !(v > 0)) die(`Invalid deadlines.${k}=${v} (expected a positive number of ms)`);
D[k] = v;
}
}
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');
}
// `--report=-` means "machine report to stdout" (Unix `-` convention).
// Only meaningful for streamable formats (json/junit); allure is a directory.
const reportToStdout = opts.report === '-';
if (reportToStdout && opts.format === 'allure') {
die('--report=- (stdout) is not valid with --format=allure: allure emits a directory of files, not a single stream. Use --report-dir=<dir> instead.');
}
const reportDir = opts.reportDir
? resolve(opts.reportDir)
: (opts.report && !reportToStdout ? dirname(resolve(opts.report)) : testDir);
if (opts.screenshot !== 'off') {
try { mkdirSync(reportDir, { recursive: true }); } catch {}
// 1C-error screenshots (taken inside the action wrapper) default to a single
// fixed file at the skill root — outside reportDir and shared by every test.
// Point them at reportDir so each failure keeps its own attachable file.
setErrorShotDir(reportDir);
}
// Discover test files
const testFiles = discoverTests(testPaths);
if (!testFiles.length) die(`No *.test.mjs files found in ${testPaths.join(', ')}`);
// 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, '/'));
}
// Human-readable report goes to stdout (test-runner convention: jest/pytest/playwright).
// In `--report -` mode the machine JSON/XML takes over stdout, so progress moves to stderr.
const W = reportToStdout ? process.stderr : process.stdout;
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;
// Per-test diagnostics are BUFFERED and flushed right after that test's ✓/✗ line.
// A test's cleanup runs before its result is printed, so writing straight to the stream put
// `! …` lines ABOVE the test they belong to — i.e. visually under the PREVIOUS test's result.
// Anyone reading the log (a model included) attributes them to the wrong test; that misreading
// already cost this session a wrong conclusion. Outside a test (hooks, final teardown) there is
// nothing to attach to, so lines go straight out.
let diagSink = null;
const emit = (line) => { if (diagSink) diagSink.push(line); else W.write(line); };
const flushDiag = () => {
if (!diagSink) return;
for (const line of diagSink) W.write(line);
diagSink = null;
};
/**
* Bounded best-effort await: the replacement for `try { await x } catch {}`.
* Same tolerance for failure, but a call that never settles can no longer stall the run,
* and every breach leaves a visible line instead of a silent 29-minute stall.
*/
async function bounded(promise, ms, label) {
const r = await softDeadline(promise, ms, label);
if (!r.ok) emit(` ! ${label}: ${r.timedOut ? `timed out after ${ms}ms` : r.err.message.split('\n')[0]}\n`);
return r;
}
/**
* Reset one context between tests — and only reuse it if the reset actually WORKED.
*
* Reusing a context whose UI was not cleaned leaks someone else's open form into the next test:
* silent drift instead of a visible error, the worst possible outcome. Two ways to end up there,
* and both must lead here:
* - the reset breached its deadline (badly-sized budget, wedged page);
* - the reset ran to completion but did not clean anything (a modal that refuses to close) —
* this one used to pass as success, because `bounded` only reports timeouts and throws.
* Either way: destroy the slot, ensureContext recreates a clean one. The cost is a relaunch,
* never a wrong test result.
*/
async function resetOrAbort(cn, ctx) {
const sw = await bounded(browser.setActiveContext(cn), D.setActive, `setActiveContext(${cn})`);
if (!sw.ok) return false;
const r = await bounded(resetState(ctx), D.resetState, `resetState(${cn})`);
if (r.ok && r.value?.clean) return true;
if (r.ok) {
// Name what stayed open — otherwise the next investigation starts from archaeology.
const v = r.value || {};
const what = v.title ? `"${v.title}"` : `#${v.form}`;
emit(` ! resetState(${cn}): not clean — form ${what}${v.modal ? ' (modal)' : ''} still open` +
` after ${v.attempts} close attempt(s)` +
`${v.lastError ? `, last error: ${v.lastError.message.split('\n')[0]}` : ''}\n`);
}
emit(` ! context "${cn}" left dirty — aborting it, the next test gets a fresh one\n`);
await bounded(browser.abortContext(cn), D.closeContext, `abortContext(${cn})`);
dropLru(lruOrder, cn);
return false;
}
// Bumped for every attempt. A timed-out test's body keeps running — a promise cannot be
// cancelled, so when its pending call finally rejects, its own `finally` would go on to
// drive the UI of whichever test is running by then. Silent cross-test corruption.
//
// The run shares one `ctx` object (hooks hold it too), so an epoch stamped on ctx could not
// tell the zombie from the live caller — both are the same object. Each attempt therefore
// gets its own Proxy view bound to its epoch; calls through a stale view throw.
let abortEpoch = 0;
function makeTestCtx(base, epoch) {
return new Proxy(base, {
get(target, prop, recv) {
const v = Reflect.get(target, prop, recv);
if (typeof v !== 'function') return v;
return (...args) => {
if (epoch !== abortEpoch) {
throw new Error(`test abandoned (timeout) — blocked a late ${String(prop)}() call from its body; it would have hit the next test`);
}
return v.apply(target, args);
};
},
});
}
function buildReport(state) {
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
return {
runner: 'web-test', url, startedAt, finishedAt: new Date().toISOString(),
state,
duration: totalDuration,
summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount },
tests: results,
};
}
let allureWritten = false;
/**
* Record a finished test AND persist it immediately. The report used to be written only
* after the loop, so a single hang destroyed every result collected so far.
* writeAllure([tr]) is byte-identical to the batch call: it mints its own uuid per test and
* severityIndex is read-only.
*/
function recordResult(tr) {
results.push(tr);
if (opts.format === 'allure') {
try { writeAllure([tr], reportDir, severityIndex); allureWritten = true; } catch (e) { W.write(` ! allure write: ${e.message}\n`); }
} else if (opts.format === 'json' && opts.report && !reportToStdout) {
try { writeFileSync(resolve(opts.report), JSON.stringify(buildReport('partial'), null, 2)); } catch {}
}
}
const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`);
const hookEnv = { hookArgs, log: hookLog, config };
// Deliberately unbounded and allowed to throw: prepare() rebuilds the stand (db-create +
// load + update), whose honest duration depends on the application's size — a deadline here
// would cut a legitimate rebuild. And its failure must stay fatal: proceeding into a run
// without a stand turns one clear error into a screenful of confusing ones.
if (hooks.prepare) await hooks.prepare(hookEnv);
/** Force-release every open context (frees 1C licenses), then drop the browser. */
async function shutdownAll() {
for (const name of browser.listContexts()) {
await bounded(browser.abortContext(name), D.closeContext, `abortContext(${name})`);
}
await bounded(browser.disconnect(), D.disconnect, 'disconnect');
}
/**
* Wall-clock ceiling for the whole run. This works even while a test is wedged: a pending
* Playwright await does not block the event loop, it is merely an unsettled promise — which
* is precisely why the original incident stalled quietly instead of crashing.
* Report first (that's what the user needs), hygiene second, exit unconditionally.
*/
let globalTimer = null;
let hardStopping = false;
async function hardStop(reason) {
if (hardStopping) return;
hardStopping = true;
W.write(`\n!! ${reason}: run exceeded --global-timeout=${opts.globalTimeout}ms — forcing shutdown\n`);
abortEpoch++;
// Last-resort exit if the shutdown itself wedges. Referenced on purpose: it must survive.
const bailout = setTimeout(() => process.exit(3), 20000);
try { writeFinalReport('aborted'); } catch (e) { W.write(` ! report: ${e.message}\n`); }
await softDeadline(shutdownAll(), 15000, 'shutdown');
clearTimeout(bailout);
process.exit(2);
}
if (opts.globalTimeout > 0) {
globalTimer = setTimeout(() => { void hardStop('global-timeout'); }, opts.globalTimeout);
}
// 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 (hosts beforeAll / hooks). It is NOT permanently
// pinned — under a maxContexts cap it becomes an LRU eviction candidate unless it is listed in
// pinnedContexts. Register it in the LRU order.
//
// This one call needs its own catch: it sits in a try that has only a `finally`, and run.mjs
// does not wrap cmdTest — so a throw here would escape as a raw stack trace and skip the
// report entirely. A blocked startup (e.g. no free 1C licence) dooms the whole run anyway,
// so say it once, keep the report, and leave.
try {
await ensureContext(defaultContextName);
} catch (e) {
W.write(`\n!! cannot open context "${defaultContextName}": ${e.message}\n\n`);
try { writeFinalReport('aborted'); } catch {}
// process.exit skips the `finally` below, and killing the process does NOT release a 1C
// seance — so release what we hold explicitly before leaving.
await softDeadline(shutdownAll(), 15000, 'shutdown');
process.exit(1);
}
touchLru(lruOrder, 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++;
// Buffer this test's diagnostics; they are flushed under its own result line below.
diagSink = [];
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`);
flushDiag();
recordResult({ 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 {
// Make room in the license pool before opening this test's contexts. Already-open needed
// contexts are reused (ensureContext no-ops); LRU-oldest non-pinned contexts are evicted.
const plan = planEviction({
open: browser.listContexts(),
needed: testContextNames,
pinned: pinnedSet,
max: maxContexts,
lruOrder,
});
if (plan.error) throw new Error(plan.error);
// Needed-but-not-yet-open contexts — also serve as a parking fallback when eviction would
// close the sole open context (can't closeContext the active slot with no survivor).
const toOpenQueue = testContextNames.filter(n => !browser.hasContext(n));
for (const name of plan.toEvict) {
if (browser.getActiveContext() === name) {
let survivor = browser.listContexts().find(n => n !== name);
if (!survivor) {
// `name` is the only open context. Open a needed one first to park on — room is
// guaranteed because we free `name` right after and multi-context implies max>=2.
if (browser.listContexts().length < maxContexts && toOpenQueue.length) {
const parkName = toOpenQueue.shift();
await ensureContext(parkName);
survivor = parkName;
} else {
throw new Error(`cannot evict "${name}": it is the only open context and maxContexts=${maxContexts} leaves no room to switch. Use maxContexts>=2 when tests alternate contexts.`);
}
}
await browser.setActiveContext(survivor);
}
if (hooks.beforeCloseContext && hookCtx) {
try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); }
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
}
await browser.closeContext(name);
dropLru(lruOrder, name);
}
for (const cn of testContextNames) await ensureContext(cn);
await browser.setActiveContext(testContextNames[0]);
touchLru(lruOrder, testContextNames);
} catch (e) {
W.write(`${t.name} (context setup failed: ${e.message})\n`);
flushDiag();
recordResult({ 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`);
const rec = await bounded(browser.startRecording(videoFile, { force: true }), D.startRecording, 'startRecording');
if (!rec.ok) 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);
}
}
const myEpoch = ++abortEpoch;
let timedOut = false;
try {
if (hooks.beforeEach) await hooks.beforeEach(ctx);
if (t.setup) await t.setup(ctx);
let timeoutTimer;
try {
await Promise.race([
t.fn(makeTestCtx(ctx, myEpoch), t.param),
new Promise((_, reject) => { timeoutTimer = setTimeout(() => { timedOut = true; reject(new Error(`Timeout (${t.timeout}ms)`)); }, t.timeout); }),
]);
} finally {
// Clear the guard timer — otherwise it stays armed in the event loop and,
// since the success path never calls process.exit(), node can't exit until
// it fires (up to `timeout` ms after the last test finished).
clearTimeout(timeoutTimer);
}
// Bounded even on the green path: a test can pass and still leave the UI in a state
// where resetState wedges — that would stall the run just as dead as a failure would.
if (t.teardown) await bounded(t.teardown(ctx), D.teardown, 'teardown');
ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps };
if (hooks.afterEach) await bounded(hooks.afterEach(ctx), D.afterEach, 'hooks.afterEach');
for (const cn of testContextNames) {
if (!browser.hasContext(cn)) continue;
await resetOrAbort(cn, ctx);
}
for (const k of scopedKeys) delete ctx[k];
if (videoFile) {
await bounded(browser.stopRecording(), D.stopRecording, 'stopRecording');
}
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) {
// ── Timeout: diagnose, then destroy what hung. Everything below this point that
// goes through the renderer (screenshot, teardown, resetState) is pointless on a
// wedged page and would itself hang — so on `hang` we skip straight to the abort.
let diagnosis = null;
if (timedOut) {
const active = browser.getActiveContext();
const probe = active ? await browser.probeContext(active, { ms: D.probe }) : null;
const diag = active ? browser.getContextDiagnostics(active) : null;
const verdict = !probe ? 'no-context'
: !probe.browserAlive ? 'browser-dead'
: !probe.rendererAlive ? 'hang'
: diag?.net.inFlight > 0 ? 'slow-network'
: 'slow';
const lines = [
`verdict: ${verdict}` + (verdict === 'hang' ? ' (renderer unresponsive, browser alive)' : ''),
` context "${active}" [${diag?.isolation}] · renderer probe: ${probe?.rendererAlive ? `ok in ${probe.rendererMs}ms` : `timed out at ${D.probe}ms`}` +
` · browser probe: ${probe?.browserAlive ? `ok in ${probe.browserMs}ms` : `timed out at ${D.probe}ms`}`,
` network: ${diag?.net.inFlight} in flight, last event ${diag?.msSinceLastNetEvent != null ? (diag.msSinceLastNetEvent / 1000).toFixed(1) + 's ago' : 'never'}` +
` (${diag?.net.requests} req / ${diag?.net.responses} resp)`,
];
// Same failure, different remedy — say which, or the next person guesses.
if (verdict === 'slow' || verdict === 'slow-network') {
lines.push(' no hang detected — the test is simply slower than its declared timeout; raise `export const timeout`');
}
if (verdict === 'hang' || verdict === 'browser-dead') {
const ab = await bounded(browser.abortContext(active), D.abortAll, 'abortContext');
const r = ab.ok ? ab.value : null;
lines.push(` recovery: ${r ? `context aborted (logout: ${r.logout}, closed: ${r.closed}${r.escalated ? ', escalated to browser kill' : ''})` : 'abort failed'} — next test recreates it`);
if (r?.notes?.length) lines.push(` notes: ${r.notes.join('; ')}`);
if (active) dropLru(lruOrder, active);
}
diagnosis = { verdict, probe, net: diag?.net };
e.message = `${e.message}${lines[0]}`;
output.push(...lines);
emit(lines.map(l => ` ${l}\n`).join(''));
}
const dead = diagnosis && (diagnosis.verdict === 'hang' || diagnosis.verdict === 'browser-dead');
// Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI.
// Skipped on a dead page: it goes through the renderer, so it can only hang.
let shotFile = e.onecError?.screenshot;
if (!shotFile && opts.screenshot !== 'off' && !dead) {
const shot = await bounded(browser.screenshot(), D.screenshot, 'screenshot');
if (shot.ok) {
try {
shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
writeFileSync(shotFile, shot.value);
} catch { shotFile = undefined; }
}
} else if (shotFile && dirname(resolve(shotFile)) !== reportDir) {
// Shot came from a context built before setErrorShotDir (e.g. a server
// session started earlier): reporters attach by basename, so anything
// outside reportDir is a dead link. Move it in under a unique name.
const dest = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
try {
renameSync(resolve(shotFile), dest);
shotFile = dest;
} catch {
try {
copyFileSync(resolve(shotFile), dest);
try { unlinkSync(resolve(shotFile)); } catch {}
shotFile = dest;
} catch {}
}
}
if (t.teardown && !dead) await bounded(t.teardown(ctx), D.teardown, 'teardown');
const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError, diagnosis };
ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps };
if (hooks.afterEach) await bounded(hooks.afterEach(ctx), D.afterEach, 'hooks.afterEach');
// resetState drives the UI (up to 10 × getFormState + closeForm, all page.evaluate).
// On a dead page it cannot succeed — the slot is already gone anyway.
if (!dead) {
for (const cn of testContextNames) {
if (!browser.hasContext(cn)) continue;
await resetOrAbort(cn, ctx);
}
}
for (const k of scopedKeys) delete ctx[k];
if (videoFile) {
await bounded(browser.stopRecording(), D.stopRecording, 'stopRecording');
}
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 };
// A wedged renderer is not flakiness — retrying just buys another full timeout
// plus another abort. Stop after the first hang.
if (dead) break;
}
}
// strict policy: release this test's non-pinned contexts right after it (all attempts done),
// instead of keeping them for reuse. Frees 1C licenses ASAP on shared/tight stands. Parks
// active on a survivor before closing; never closes the sole remaining context.
if (contextPolicy === 'strict') {
for (const name of testContextNames) {
if (pinnedSet.has(name) || !browser.hasContext(name)) continue;
if (browser.getActiveContext() === name) {
const survivor = browser.listContexts().find(n => n !== name);
if (!survivor) continue; // can't close the sole active context — leave it open
try { await browser.setActiveContext(survivor); } catch {}
}
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 {}
dropLru(lruOrder, name);
}
}
recordResult(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`);
}
flushDiag();
if (opts.bail && testResult.status === 'failed') break;
}
// Out of the per-test scope (also on `break`): afterAll and the final teardown have no test
// to nest under, so their diagnostics go straight to the stream again.
flushDiag();
if (hooks.afterAll) await bounded(hooks.afterAll(ctx), D.hooks, 'hooks.afterAll');
} finally {
clearTimeout(globalTimer);
// Per-context teardown
try {
const remaining = browser.listContexts();
if (remaining.length > 0) {
const survivor = remaining[0];
await bounded(browser.setActiveContext(survivor), D.setActive, `setActiveContext(${survivor})`);
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]}`); }
}
// closeContext goes through the page (logout + close). If it breaches, fall back to
// abortContext: it logs out from Node, which is the path that survives a dead page.
const cc = await bounded(browser.closeContext(name), D.closeContext, `closeContext(${name})`);
if (!cc.ok) await bounded(browser.abortContext(name), D.closeContext, `abortContext(${name})`);
}
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]}`);
}
await bounded(browser.disconnect(), D.disconnect, 'disconnect');
if (hooks.cleanup) await bounded(hooks.cleanup(hookEnv), D.hooks, 'hooks.cleanup');
}
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`);
writeFinalReport('complete');
if (failCount > 0) process.exit(1);
/**
* Allure results are already on disk (recordResult writes each test as it finishes), so this
* only completes the formats that need whole-run totals. Also called from hardStop, where
* `state` is 'aborted' and `results` holds whatever finished before the ceiling hit.
*/
function writeFinalReport(state) {
const report = buildReport(state);
if (opts.format === 'allure') {
// Guard against a result-producing path that skipped recordResult; normally a no-op.
if (!allureWritten) writeAllure(results, reportDir, severityIndex);
syncAllureExtras(testDir, reportDir);
} else if (opts.format === 'junit') {
if (reportToStdout) process.stdout.write(buildJUnit(report, testDir) + '\n');
else writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
} else if (reportToStdout) {
out(report);
} else if (opts.report) {
writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2));
}
}
}
@@ -0,0 +1,164 @@
// web-test cli/exec-context v1.1 — 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, slugify } from './util.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_ERROR_SHOT_PATH = resolve(__dirname, '..', '..', 'error-shot.png');
// Where 1C-error screenshots go. The default is a single fixed file: fine for
// interactive `exec`/`run` (last error wins, easy to find), wrong for a test run
// where every test needs its own file inside reportDir. cmdTest overrides this.
let errorShotDir = null;
let errorShotSeq = 0;
export function setErrorShotDir(dir) {
errorShotDir = dir;
errorShotSeq = 0;
}
function nextErrorShotPath(label) {
if (!errorShotDir) return DEFAULT_ERROR_SHOT_PATH;
return resolve(errorShotDir, `onec-error-${++errorShotSeq}-${slugify(label || '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 = nextErrorShotPath(name);
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 = nextErrorShotPath('script');
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;
}
}
@@ -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);
}
}
@@ -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 <url>');
}
return JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
}
export function cleanup() {
try { unlinkSync(SESSION_FILE); } catch {}
}
@@ -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);
},
};
}
@@ -0,0 +1,87 @@
// web-test cli/test-runner/context-pool v1.0 — pure context-pool planner (LRU eviction).
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Decides which already-open contexts (each = one live 1C session = one license) to evict
// so the next test's declared contexts fit within `maxContexts` simultaneous sessions.
// Pure functions, no browser — unit-tested in context-pool.test.mjs.
/**
* @param {object} p
* @param {string[]} p.open currently open context names (live 1C sessions)
* @param {string[]} p.needed context names the next test declares
* @param {Set<string>|string[]} [p.pinned] never-evict context names
* @param {number|null} [p.max] simultaneous-session cap; null/undefined = unlimited
* @param {string[]} [p.lruOrder] usage order, oldest first / freshest last
* @returns {{ toEvict: string[], error: string|null }}
*/
export function planEviction({ open = [], needed = [], pinned = [], max = null, lruOrder = [] }) {
const pinnedSet = pinned instanceof Set ? pinned : new Set(pinned);
const neededSet = new Set(needed);
const openSet = new Set(open);
// Unlimited pool → never evict (back-compat: behaves like the pre-pool runner).
if (max == null) return { toEvict: [], error: null };
// Lower bound that must stay live regardless of eviction: this test's needed contexts, plus
// pinned contexts that are ALREADY open (pinned = "don't evict while open", NOT "always open" —
// a pinned context that is currently closed does not count against this test's budget).
const mustStay = new Set(needed);
for (const p of pinnedSet) if (openSet.has(p)) mustStay.add(p);
if (mustStay.size > max) {
return {
toEvict: [],
error: `context pool exhausted: this test needs ${mustStay.size} simultaneous 1C sessions `
+ `(declared contexts + already-open pinned) but maxContexts=${max}. `
+ `Raise maxContexts, reduce declared contexts, or shrink pinnedContexts.`,
};
}
// projected = everything live once we open `needed`. If it already fits, nothing to evict.
const projected = new Set([...open, ...needed]);
if (projected.size <= max) return { toEvict: [], error: null };
// Evictable = open, not pinned, not needed — oldest first by lruOrder.
const evictable = [];
for (const name of lruOrder) {
if (openSet.has(name) && !pinnedSet.has(name) && !neededSet.has(name)) evictable.push(name);
}
// Any open evictable missing from lruOrder → treat as oldest (evict first).
for (const name of open) {
if (!lruOrder.includes(name) && !pinnedSet.has(name) && !neededSet.has(name)) {
evictable.unshift(name);
}
}
const toEvict = [];
let size = projected.size;
for (const name of evictable) {
if (size <= max) break;
toEvict.push(name);
size--;
}
// Guaranteed size <= max here: after removing all evictable, projected collapses to
// (open ∩ pinned) needed == mustStay, and mustStay.size <= max passed the guard above.
return { toEvict, error: null };
}
/**
* Move `names` to the fresh end of the LRU order (most-recently-used last). Mutates and returns
* `lruOrder`. Idempotent per name — existing entries are relocated, not duplicated.
*/
export function touchLru(lruOrder, names) {
for (const n of (Array.isArray(names) ? names : [names])) {
const i = lruOrder.indexOf(n);
if (i >= 0) lruOrder.splice(i, 1);
lruOrder.push(n);
}
return lruOrder;
}
/** Remove `names` from the LRU order (e.g. after a context is closed). Mutates and returns it. */
export function dropLru(lruOrder, names) {
for (const n of (Array.isArray(names) ? names : [names])) {
const i = lruOrder.indexOf(n);
if (i >= 0) lruOrder.splice(i, 1);
}
return lruOrder;
}
@@ -0,0 +1,82 @@
// web-test cli/test-runner/discover v1.3 — 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';
// Accepts a single path or an array of paths (files and/or dirs). Each .test.mjs file is
// taken directly; each directory is walked recursively (skipping _ / . prefixes). Results
// are deduped and sorted — sorting preserves the numeric-prefix order the suite relies on
// (00-, 01-, …) even when paths are listed out of order.
export function discoverTests(testPaths) {
const paths = Array.isArray(testPaths) ? testPaths : [testPaths];
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);
}
}
for (const p of paths) {
const full = resolve(p);
if (full.endsWith('.test.mjs')) {
if (existsSync(full)) files.push(full);
} else if (existsSync(full)) {
walk(full);
}
}
return [...new Set(files)].sort();
}
/**
* Return the context to a clean desktop between tests — and REPORT whether that worked.
*
* The verdict is the point. closeForm does not throw when a form refuses to close: it returns
* `{closed:false}`, and this loop used to drop that on the floor, so a context with someone else's
* modal still open went back into the pool as "clean" and the next test clicked into it. Measured
* on the pilot's stand: 10 idle iterations, `closed:false` every time, state unchanged — and the
* runner called it a success.
*
* @returns {Promise<{clean: boolean, attempts: number, form?: any, title?: string, modal?: boolean, lastError?: Error}>}
* `clean:false` also when the check itself failed — not being able to confirm is not being clean.
*/
export async function resetState(ctx) {
try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {}
let attempts = 0;
let lastError = null;
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) return { clean: true, attempts };
attempts++;
const r = await ctx.closeForm({ save: false });
// The platform found nothing closable → this is the desktop, however many forms sit on it.
// Without this the check would be "form == null", which is only true for an EMPTY desktop:
// on a real application the home page keeps its own forms (measured: form=5, formCount=3,
// no cross), so the old rule declared a perfectly clean context dirty after every test.
if (r?.nothingToClose) return { clean: true, attempts, desktop: true };
// Deliberately NOT bailing out on `closed:false`: measured A/B on the live suite — a dirty
// «Приходная накладная *» reports closed:false on the first round and closes on a later one,
// so an early exit aborted a context that was about to be clean. `closed` compares form
// numbers, so an intermediate step (a popup going away) reads as "nothing happened" even
// though progress was made. The verdict below judges the END state, which is what matters.
} catch (e) { lastError = e; break; }
}
// Control check — the loop proves nothing on its own: it can also exit via `catch` above.
try {
const state = await ctx.getFormState();
if (state.form == null) return { clean: true, attempts };
return {
clean: false, attempts, lastError,
form: state.form,
title: state.activeTab || null,
modal: !!state.modal,
};
} catch (e) {
return { clean: false, attempts, lastError: lastError || e };
}
}
@@ -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 `<testDir>/_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 = ['<?xml version="1.0" encoding="UTF-8"?>'];
lines.push(`<testsuites name="web-test" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${duration.toFixed(3)}">`);
lines.push(` <testsuite name="${xmlEscape(suiteName)}" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${duration.toFixed(3)}">`);
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(` <testcase ${attrs}/>`);
} else if (t.status === 'skipped') {
lines.push(` <testcase ${attrs}><skipped/></testcase>`);
} else {
lines.push(` <testcase ${attrs}>`);
const msg = t.error?.message || '';
const trace = t.output || '';
lines.push(` <failure message="${xmlEscape(msg)}">${xmlEscape(trace)}</failure>`);
if (t.screenshot) lines.push(` <system-out>screenshot: ${xmlEscape(t.screenshot)}</system-out>`);
lines.push(` </testcase>`);
}
}
lines.push(` </testsuite>`);
lines.push(`</testsuites>`);
return lines.join('\n');
}
@@ -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<tag, severity> (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;
}
@@ -0,0 +1,134 @@
// web-test cli/util v1.4 — generic helpers for CLI commands
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
// Wall-clock bounds live in the engine (session.mjs needs them too, and the engine must not
// depend on cli/). Re-exported here so CLI callers have one import site.
export { withDeadline, softDeadline, DeadlineError } from '../engine/core/deadline.mjs';
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;
}
const TRANSLIT = {
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i',
й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't',
у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '',
э: 'e', ю: 'yu', я: 'ya',
};
/**
* ASCII-only slug for artifact file names (screenshots, videos).
* Non-ASCII names are unusable as Allure attachments: the Allure CLI silently
* fails to resolve them and emits `"size": 0` with no link to the file
* (JAVA_OPTS encoding flags do not help). Cyrillic is transliterated so the
* name stays readable; anything else non-ASCII collapses to `-`.
*/
export function slugify(s) {
const ascii = String(s).trim().toLowerCase()
.replace(/[а-яё]/g, ch => TRANSLIT[ch] ?? '-');
return ascii
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60)
.replace(/^-|-$/g, '') || '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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
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 <command> [args]
Commands:
start <url> Launch browser and connect to 1C web client
run <url> <file|-> Autonomous: connect, execute script, disconnect
exec <file|-> [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 <dir|file>... Run regression tests (*.test.mjs); accepts multiple paths
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:
--url=URL Override the base URL (default: from webtest.config.mjs)
--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 machine report (JSON/JUnit) to file
--report=- Write machine report to stdout (progress moves to stderr)
--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)
-- <hook-args...> Everything after \`--\` is forwarded to _hooks.mjs
prepare/cleanup as hookArgs (runner does not parse it).
Example: ... tests/web-test/ -- --rebuild-stand`);
}