mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-18 16:49:41 +03:00
Auto-build: opencode (python) from 37f39a5
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user