Auto-build: opencode (python) from 37f39a5

This commit is contained in:
github-actions[bot]
2026-07-17 17:43:13 +00:00
commit 80347c561f
303 changed files with 146092 additions and 0 deletions
@@ -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;
}