merge: web-test engine refactor (Phase 1-5 + multi-select tests + README)

This commit is contained in:
Nick Shirokov
2026-05-28 13:28:05 +03:00
61 changed files with 9924 additions and 8991 deletions
+9 -4
View File
@@ -217,6 +217,8 @@ Sections + all open tabs.
### Actions
**Return shape convention.** All action functions return a **flat form state** (same shape as `getFormState()`) with action-specific extras: `clicked`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`. Errors always sit at the top level under `.errors` (when present) — the exec-wrapper automatically throws on `.errors.modal` / `.errors.balloon`.
#### `clickElement(text, { dblclick?, table?, expand?, modifier? })` → form state
Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
@@ -267,7 +269,7 @@ Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
await clickElement('150 000', { dblclick: true }); // finds cell by text in report
```
#### `fillFields({ name: value })` → `{ filled, form }`
#### `fillFields({ name: value })` → form state with `filled`
Fill form fields by label (fuzzy match). Auto-detects field type.
| Value | Field type | Method |
@@ -286,8 +288,7 @@ await fillFields({
});
```
Returns `{ filled: [{ field, ok, value, method }], form: {...} }`.
Method is one of: `'clear'` | `'toggle'` | `'radio'` | `'paste'` | `'dropdown'` | `'form'` | `'typeahead'`
Returns form state with `filled: [{ field, ok: true, value, method }]` (method: `clear`|`toggle`|`radio`|`paste`|`dropdown`|`form`|`typeahead`). **Throws on any per-field failure** with a detailed message listing problematic fields and available options — if the call returned, all fields were filled, no per-item check needed.
#### `selectValue(field, search, opts?)` → form state with `selected`
Select a value from reference field via dropdown or selection form. More reliable than `fillFields` for reference fields that need exact selection from a catalog. Pass empty `search` (`''` or `null`) to clear the field (Shift+F4).
@@ -310,9 +311,13 @@ await selectValue('Документ', '0000-000601', { type: 'Реализаци
Also supports DCS labels — auto-enables the paired checkbox.
#### `fillTableRow(fields, opts)` → form state
#### `fillTableRow(fields, opts)` → form state with `filled` (+ optional `notFilled`)
Fill table row cells via Tab navigation. Value is a plain string, `{ value, type }` for composite-type cells, or `''`/`null` to clear (Shift+F4).
Returns form state with `filled: [{ field, ok, ...}]`. Items are `{ field, ok: true, method, value }` on success (method: `direct`|`paste`|`dropdown`|`form`|`type-direct`|`skip`|`clear`|`toggle`) or `{ field, ok: false, error, message }` on per-field failure. Unmatched fields → `notFilled: [...]`.
**Unlike `fillFields`, `fillTableRow` does NOT throw on per-field failures** — errors appear as `ok: false` items in `filled[]` so the caller can react selectively (e.g. retry one cell while the rest of the row stays filled). Check via `r.filled.filter(f => !f.ok)`. Error codes: `composite_type`/`type_required`/`type_dialog_failed` (retry with `{value, type}`); `column_not_found` (check column name via `readTable`); `no_selection_form`/`no_selection_after_type` (retry or fall back to `selectValue`); `not_found`/`no_match`/`ambiguous` (refine search text); `still_open` (picked a group — pick a leaf row). Soft validation errors from 1C (`balloon`, `modal`) still throw via the exec-wrapper.
| Option | Description |
|--------|-------------|
| `tab` | Switch to tab before filling |
File diff suppressed because it is too large Load Diff
@@ -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,22 @@
// web-test cli/commands/run v1.0 — 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');
await browser.connect(url);
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,33 @@
// web-test cli/commands/start v1.0
// 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>');
const state = await browser.connect(url);
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,14 @@
// web-test cli/commands/status v1.0 — check session
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { existsSync, readFileSync } from 'fs';
import { out } from '../util.mjs';
import { SESSION_FILE } from '../session.mjs';
export function cmdStatus() {
if (!existsSync(SESSION_FILE)) {
out({ ok: false, message: 'No active session' });
process.exit(1);
}
const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
out({ ok: true, ...sess });
}
@@ -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,433 @@
// web-test cli/commands/test v1.0 — regression test runner
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { existsSync, writeFileSync, mkdirSync } from 'fs';
import { resolve, dirname, basename, relative } from 'path';
import * as browser from '../../browser.mjs';
import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps } from '../util.mjs';
import { buildContext, buildScopedContext } 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';
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) : [];
// Parse flags
const opts = { bail: false, retry: 0, timeout: 30000, report: null, format: 'json', screenshot: null, reportDir: null, record: false };
let tags = null, grep = 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 === '--bail') opts.bail = true;
else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0;
else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000;
else if (a.startsWith('--report=')) opts.report = a.slice(9);
else if (a.startsWith('--format=')) opts.format = a.slice(9);
else if (a.startsWith('--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);
}
// Determine URL and test path
let url, testPath;
if (positional.length === 2) {
url = positional[0];
testPath = resolve(positional[1]);
} else if (positional.length === 1) {
testPath = resolve(positional[0]);
} else {
die('Usage: node run.mjs test [url] <dir|file> [--tags=...] [--bail] [--retry=N] [--timeout=ms] [--report=path]');
}
// Load config if exists
const isFile = testPath.endsWith('.test.mjs');
const testDir = isFile ? dirname(testPath) : testPath;
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;
// 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);
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');
}
const reportDir = opts.reportDir
? resolve(opts.reportDir)
: (opts.report ? dirname(resolve(opts.report)) : testDir);
if (opts.screenshot !== 'off') {
try { mkdirSync(reportDir, { recursive: true }); } catch {}
}
// Discover test files
const testFiles = discoverTests(testPath);
if (!testFiles.length) die(`No *.test.mjs files found in ${testPath}`);
// Import and filter tests
const tests = [];
let hasOnly = false;
for (const file of testFiles) {
const mod = await import('file:///' + file.replace(/\\/g, '/'));
const 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, '/'));
}
// Console header
const W = process.stderr;
W.write(`\nweb-test -- ${url}\n`);
W.write(`Running ${filtered.length} tests from ${relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'}/\n\n`);
const startedAt = new Date().toISOString();
const results = [];
let passCount = 0, failCount = 0, skipCount = 0;
const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`);
const hookEnv = { hookArgs, log: hookLog, config };
if (hooks.prepare) await hooks.prepare(hookEnv);
// 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
await ensureContext(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++;
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`);
results.push({ 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 {
for (const cn of testContextNames) await ensureContext(cn);
await browser.setActiveContext(testContextNames[0]);
} catch (e) {
W.write(`${t.name} (context setup failed: ${e.message})\n`);
results.push({ 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`);
try { await browser.startRecording(videoFile, { force: true }); } catch { 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);
}
}
try {
if (hooks.beforeEach) await hooks.beforeEach(ctx);
if (t.setup) await t.setup(ctx);
await Promise.race([
t.fn(ctx, t.param),
new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout (${t.timeout}ms)`)), t.timeout)),
]);
if (t.teardown) try { await t.teardown(ctx); } catch {}
ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps };
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
for (const cn of testContextNames) {
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
}
for (const k of scopedKeys) delete ctx[k];
if (videoFile) {
try { await browser.stopRecording(); } catch {}
}
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) {
// Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI.
let shotFile = e.onecError?.screenshot;
if (!shotFile && opts.screenshot !== 'off') {
try {
const png = await browser.screenshot();
shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
writeFileSync(shotFile, png);
} catch {}
}
if (t.teardown) try { await t.teardown(ctx); } catch {}
const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError };
ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps };
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
for (const cn of testContextNames) {
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
}
for (const k of scopedKeys) delete ctx[k];
if (videoFile) {
try { await browser.stopRecording(); } catch {}
}
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 };
}
}
results.push(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`);
}
if (opts.bail && testResult.status === 'failed') break;
}
if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {}
} finally {
// Per-context teardown
try {
const remaining = browser.listContexts();
if (remaining.length > 0) {
const survivor = remaining[0];
try { await browser.setActiveContext(survivor); } catch {}
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]}`); }
}
try { await browser.closeContext(name); }
catch (e) { hookLog(`closeContext("${name}") failed: ${e.message.split('\n')[0]}`); }
}
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]}`);
}
try { await browser.disconnect(); } catch {}
if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {}
}
const finishedAt = new Date().toISOString();
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`);
const report = {
runner: 'web-test', url, startedAt, finishedAt,
duration: totalDuration,
summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount },
tests: results,
};
out(report);
if (opts.format === 'allure') {
writeAllure(results, reportDir, severityIndex);
syncAllureExtras(testDir, reportDir);
} else if (opts.format === 'junit') {
writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
} else if (opts.report) {
writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2));
}
if (failCount > 0) process.exit(1);
}
@@ -0,0 +1,148 @@
// web-test cli/exec-context v1.0 — 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 } from './util.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ERROR_SHOT_PATH = resolve(__dirname, '..', '..', 'error-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 = ERROR_SHOT_PATH;
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 = ERROR_SHOT_PATH;
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,32 @@
// web-test cli/test-runner/discover v1.0 — 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';
export function discoverTests(testPath) {
if (testPath.endsWith('.test.mjs')) return existsSync(testPath) ? [testPath] : [];
const files = [];
function walk(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
const full = resolve(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith('.test.mjs')) files.push(full);
}
}
walk(testPath);
return files.sort();
}
export async function resetState(ctx) {
try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {}
for (let i = 0; i < 10; i++) {
try {
const state = await ctx.getFormState();
// 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) break;
await ctx.closeForm({ save: false });
} catch { break; }
}
}
@@ -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,111 @@
// web-test cli/util v1.0 — generic helpers for CLI commands
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
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;
}
export function slugify(s) {
return String(s).trim()
.replace(/[\s/\\:*?"<>|]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60) || '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 [url] <dir|file> Run regression tests (*.test.mjs)
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:
--tags=smoke,crud Filter tests by tags
--grep=pattern Filter tests by name (regex)
--bail Stop on first failure
--retry=N Retry failed tests N times
--timeout=ms Per-test timeout (default: 30000)
--report=path Write JSON report to file
--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`);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
// web-test dom shared v1.0 — embedded JS function constants
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Shared function strings embedded into page.evaluate() generators.
* Не экспортируются наружу через dom.mjs facade — внутренняя кухня.
*/
/** Find visible #modalSurface. 1C may leave multiple #modalSurface in DOM (duplicate id),
* e.g. when a second form (drill-down) creates its own alongside a stale one from the first
* form. getElementById returns the FIRST in document order, which may be hidden. Scan all. */
export const HAS_VISIBLE_MODAL_FN = `function hasVisibleModal() {
const all = document.querySelectorAll('#modalSurface');
for (const el of all) { if (el.offsetWidth > 0) return true; }
return false;
}`;
/** Detect active form number. Picks form with most visible elements, skipping form0.
* When modalSurface is visible — prefer the highest-numbered form (modal dialog). */
export const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + `
function detectForm() {
const counts = {};
document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => {
if (el.offsetWidth === 0) return;
const m = el.id.match(/^form(\\d+)_/);
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
});
const nums = Object.keys(counts).map(Number);
if (!nums.length) return null;
const candidates = nums.filter(n => n > 0);
if (!candidates.length) return nums[0];
// When modal surface is visible, prefer the highest-numbered form (modal dialog)
if (hasVisibleModal()) {
const maxForm = Math.max(...candidates);
if (counts[maxForm] >= 1) return maxForm;
}
return candidates.reduce((best, n) => counts[n] > counts[best] ? n : best);
}`;
/** Detect all open forms + modal state. Returns { activeForm, allForms, formCount, modal }.
* Works even when the open-windows tab bar is hidden. */
export const DETECT_FORMS_FN = HAS_VISIBLE_MODAL_FN + `
function detectForms() {
const counts = {};
document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => {
if (el.offsetWidth === 0) return;
const m = el.id.match(/^form(\\d+)_/);
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
});
const nums = Object.keys(counts).map(Number);
return { allForms: nums.sort((a, b) => a - b), formCount: nums.length, modal: hasVisibleModal() };
}`;
/** Read form state given prefix p. Returns { fields, buttons, tabs, texts, hyperlinks, table, iframes }. */
export const READ_FORM_FN = `function readForm(p) {
const result = {};
const fields = [];
const buttons = [];
const formTabs = [];
const texts = [];
const hyperlinks = [];
// Normalize non-breaking spaces to regular spaces
const nbsp = s => (s || '').replace(/\\u00a0/g, ' ');
// Fields (inputs)
document.querySelectorAll('input.editInput[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
const titleEl = document.getElementById(p + name + '#title_text')
|| document.getElementById(p + name + '#title_div');
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
const actions = [];
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) actions.push('select');
if (document.getElementById(p + name + '_OB')?.offsetWidth > 0) actions.push('open');
if (document.getElementById(p + name + '_CLR')?.offsetWidth > 0) actions.push('clear');
if (document.getElementById(p + name + '_CB')?.offsetWidth > 0) actions.push('pick');
const field = { name, value: el.value || '' };
// Multi-value reference fields keep their value in .chipsItem chips, not in input.value
if (!field.value) {
const labelEl = document.getElementById(p + name);
if (labelEl) {
const chipTexts = [...labelEl.querySelectorAll('.chipsItem .chipsTitle')]
.map(c => nbsp(c.innerText?.trim() || ''))
.filter(Boolean);
if (chipTexts.length) field.value = chipTexts.join(', ');
}
}
if (label && label !== name) field.label = label;
if (el.readOnly) field.readonly = true;
if (el.disabled) field.disabled = true;
if (el.type && el.type !== 'text') field.type = el.type;
if (document.activeElement === el) field.focused = true;
if (actions.length) field.actions = actions;
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
fields.push(field);
});
// Textareas
document.querySelectorAll('textarea[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
const titleEl = document.getElementById(p + name + '#title_text')
|| document.getElementById(p + name + '#title_div');
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
const field = { name, value: el.value || '', type: 'textarea' };
if (label && label !== name) field.label = label;
if (el.readOnly) field.readonly = true;
if (el.disabled) field.disabled = true;
if (document.activeElement === el) field.focused = true;
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
fields.push(field);
});
// Checkboxes
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '');
const titleEl = document.getElementById(p + name + '#title_text');
const label = nbsp(titleEl?.innerText?.trim() || '');
const field = {
name,
value: el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'),
type: 'checkbox'
};
if (label && label !== name) field.label = label;
fields.push(field);
});
// Radio buttons — base element is option 0, others are #N#radio (N >= 1)
const radioGroups = {};
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
if (el.offsetWidth === 0) return;
const id = el.id.replace(p, '');
const m = id.match(/^(.+?)#(\\d+)#radio$/);
if (m) {
// Options 1, 2, ... have explicit #N#radio suffix
const [, groupName, idx] = m;
if (!radioGroups[groupName]) radioGroups[groupName] = [];
const labelEl = document.getElementById(p + groupName + '#' + idx + '#radio_text');
const label = nbsp(labelEl?.innerText?.trim() || 'option' + idx);
radioGroups[groupName].push({ index: parseInt(idx), label, selected: el.classList.contains('select') });
} else if (!id.includes('#')) {
// Base element = option 0 (no #0#radio suffix)
if (!radioGroups[id]) radioGroups[id] = [];
const labelEl = document.getElementById(p + id + '#0#radio_text');
const label = nbsp(labelEl?.innerText?.trim() || 'option0');
radioGroups[id].unshift({ index: 0, label, selected: el.classList.contains('select') });
}
});
for (const [name, options] of Object.entries(radioGroups)) {
const titleEl = document.getElementById(p + name + '#title_text');
const label = titleEl?.innerText?.trim() || '';
const selected = options.find(o => o.selected);
const field = {
name,
value: selected?.label || '',
type: 'radio',
options: options.map(o => o.label)
};
if (label && label !== name) field.label = label;
fields.push(field);
}
// Buttons (a.press)
document.querySelectorAll('a.press[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const idName = el.id.replace(p, '');
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
const span = el.querySelector('.submenuText') || el.querySelector('span');
const text = nbsp(span?.textContent?.trim() || el.innerText?.trim() || '');
if (!text && !el.classList.contains('pressCommand')) return;
const btn = { name: text || idName };
if (el.classList.contains('pressDefault')) btn.default = true;
if (el.classList.contains('pressDisabled')) btn.disabled = true;
// Icon-only buttons: expose tooltip from DOM title attribute (1C puts title on parent .framePress)
if (!text) {
const tip = nbsp(el.title || el.parentElement?.title || '');
if (tip) btn.tooltip = tip;
}
buttons.push(btn);
});
// Frame buttons
document.querySelectorAll('[id^="' + p + '"].frameButton, [id^="' + p + '"] .frameButton').forEach(el => {
if (el.offsetWidth === 0) return;
const text = nbsp(el.innerText?.trim() || '');
const idName = el.id?.replace(p, '') || '';
if (!text && !idName) return;
buttons.push({ name: text || idName, frame: true });
});
// Tumbler items
document.querySelectorAll('[id^="' + p + '"].tumblerItem').forEach(el => {
if (el.offsetWidth === 0) return;
const text = el.innerText?.trim();
const idName = el.id?.replace(p, '') || '';
buttons.push({ name: text || idName, tumbler: true });
});
// Tabs — scoped to form by checking ancestor IDs
document.querySelectorAll('[data-content]').forEach(el => {
if (el.offsetWidth === 0) return;
let node = el.parentElement;
let inForm = false;
while (node) {
if (node.id && node.id.startsWith(p)) { inForm = true; break; }
node = node.parentElement;
}
if (!inForm) return;
const tab = { name: el.dataset.content };
if (el.classList.contains('select')) tab.active = true;
formTabs.push(tab);
});
// Static texts and hyperlinks
document.querySelectorAll('[id^="' + p + '"].staticText').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '');
if (name.endsWith('_div') || name.includes('#title')) return;
const text = el.innerText?.trim();
if (!text) return;
if (el.classList.contains('staticTextHyper')) {
hyperlinks.push({ name: text });
} else {
const titleEl = document.getElementById(p + name + '#title_text');
const label = titleEl?.innerText?.trim() || '';
const entry = { name, value: text };
if (label) entry.label = label;
texts.push(entry);
}
});
// Tables/grids — collect ALL visible grids
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
if (allGrids.length > 0) {
const tables = allGrids.map(grid => {
const name = grid.id ? grid.id.replace(p, '') : '';
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
const columns = [];
if (head) {
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (text) {
const r = box.getBoundingClientRect();
columns.push({ text, x: r.x, right: r.x + r.width, y: r.y, h: r.height });
} else {
// Unnamed column — check if data cells contain checkboxes
const firstLine = body?.querySelector('.gridLine');
if (firstLine) {
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
const idx = visibleHeaders.indexOf(box);
const cells = [...firstLine.children].filter(c => c.offsetWidth > 0);
if (cells[idx]?.querySelector('.checkbox')) {
columns.push({ text: '(checkbox)', x: 0, right: 0, y: 0, h: 0 });
}
}
}
});
// Expand single merged headers with multiple data sub-rows (e.g. "Субконто Дт" → 1/2/3)
const firstLine = body?.querySelector('.gridLine');
if (firstLine && columns.length > 0) {
const xGrp = new Map();
columns.forEach(c => {
const k = Math.round(c.x) + ':' + Math.round(c.right);
if (!xGrp.has(k)) xGrp.set(k, []);
xGrp.get(k).push(c);
});
for (const [k, hdrs] of xGrp) {
if (hdrs.length !== 1) continue;
let cnt = 0;
[...firstLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const r = box.getBoundingClientRect();
const cx = r.x + r.width / 2;
if (cx >= hdrs[0].x && cx < hdrs[0].right) cnt++;
});
if (cnt > 1) {
const base = hdrs[0];
const baseIdx = columns.indexOf(base);
columns.splice(baseIdx, 1);
for (let si = 0; si < cnt; si++) {
columns.splice(baseIdx + si, 0, { text: base.text + ' ' + (si + 1), x: base.x, right: base.right, y: 0, h: 0 });
}
}
}
}
}
const colNames = columns.map(c => c.text);
const rowCount = body ? body.querySelectorAll('.gridLine').length : 0;
// Visual label from group title (e.g. "Входящие:" for grid "Входящие")
const titleEl = document.getElementById(p + name + '#title_div')
|| document.getElementById(p + 'Группа' + name + '#title_div');
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\\s*$/, '').replace(/\\u00a0/g, ' ') || null) : null;
return { name, columns: colNames, rowCount, ...(label ? { label } : {}) };
});
result.tables = tables;
// Backward compat: table = first grid summary
const first = tables[0];
result.table = { present: true, columns: first.columns, rowCount: first.rowCount };
}
// Active filters (train badges above grid: *СостояниеПросмотра)
const filters = [];
document.querySelectorAll('[id^="' + p + '"].trainItem').forEach(el => {
if (el.offsetWidth === 0) return;
const titleEl = el.querySelector('.trainName');
const valueEl = el.querySelector('.trainTitle');
if (!titleEl && !valueEl) return;
const field = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/\\s*:$/, '').trim();
const value = valueEl?.innerText?.trim()?.replace(/\\n/g, ' ') || '';
if (field || value) filters.push({ field, value });
});
// Also check search field value
const searchInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
if (searchInput?.value) {
filters.push({ type: 'search', value: searchInput.value });
}
if (filters.length) result.filters = filters;
// Navigation panel (FormNavigationPanel) — lives in parent page{N} container
const navigation = [];
const formEl = document.querySelector('[id^="' + p + '"]');
if (formEl) {
let pageEl = formEl.parentElement;
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
if (pageEl) {
pageEl.querySelectorAll('.navigationItem').forEach(el => {
if (el.offsetWidth === 0) return;
const nameEl = el.querySelector('.navigationItemName');
const text = (nameEl?.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
if (!text) return;
const nav = { name: text };
if (el.classList.contains('select')) nav.active = true;
navigation.push(nav);
});
}
}
// Iframes
let iframeCount = 0;
document.querySelectorAll('[id^="' + p + '"] iframe, iframe[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth > 0 && el.offsetHeight > 0) iframeCount++;
});
if (iframeCount) result.iframes = iframeCount;
if (fields.length) result.fields = fields;
if (buttons.length) result.buttons = buttons;
if (formTabs.length) result.tabs = formTabs;
if (navigation.length) result.navigation = navigation;
if (texts.length) result.texts = texts;
if (hyperlinks.length) result.hyperlinks = hyperlinks;
// Group DCS report settings into readable format
if (result.fields) {
const dcsRe = /^(.+Элемент(\\d+))(Использование|Значение|ВидСравнения)$/;
const dcsGroups = {};
const dcsNames = new Set();
for (const f of result.fields) {
const m = f.name.match(dcsRe);
if (!m) continue;
if (!dcsGroups[m[1]]) dcsGroups[m[1]] = { _n: parseInt(m[2]) };
dcsGroups[m[1]][m[3]] = f;
dcsNames.add(f.name);
}
const dcsEntries = Object.entries(dcsGroups).sort((a, b) => a[1]._n - b[1]._n);
if (dcsEntries.length) {
result.reportSettings = dcsEntries.map(([, g]) => {
const cb = g['Использование'];
const val = g['Значение'];
if (!cb && !val) return null;
// No checkbox present (class="staticText" instead of .checkbox) — setting is always enabled
const label = (val?.label || cb?.label || val?.name || cb?.name || '').replace(/:$/, '').trim();
const s = { name: label, enabled: cb ? !!cb.value : true };
if (val) {
s.value = val.value || '';
if (val.actions && val.actions.length) s.actions = val.actions;
}
return s;
}).filter(Boolean);
result.fields = result.fields.filter(f => !dcsNames.has(f.name));
if (!result.fields.length) delete result.fields;
}
}
return result;
}`;
+108
View File
@@ -0,0 +1,108 @@
// web-test dom/edd v1.0 — DOM scripts for the #editDropDown autocomplete popup
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Read the `#editDropDown` autocomplete popup.
*
* Returns `{ visible: false }` when EDD is absent/hidden, or
* `{ visible: true, items: [{ name, x, y }] }` with center coords suitable
* for `page.mouse.click(x, y)`.
*
* Note: `page.mouse.click` is often intercepted by `div.surface` overlays
* from DLB — prefer `clickEddItemViaDispatchScript` for those cases.
*/
export function readEddScript() {
return `(() => {
const edd = document.getElementById('editDropDown');
if (!edd || edd.offsetWidth === 0) return { visible: false };
const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
return {
visible: true,
items: eddTexts.map(el => {
const r = el.getBoundingClientRect();
return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 };
})
};
})()`;
}
/**
* Is the EDD popup currently visible? Returns boolean.
* Lighter than `readEddScript` when only presence matters.
*/
export function isEddVisibleScript() {
return `(() => {
const edd = document.getElementById('editDropDown');
return !!(edd && edd.offsetWidth > 0);
})()`;
}
/**
* Click an EDD item by name via `dispatchEvent` — bypasses `div.surface`
* overlays from DLB that intercept `page.mouse.click`.
*
* Matching is fuzzy: exact (with optional `(suffix)` strip) → includes,
* normalizes ё/е and NBSP.
*
* Returns the clicked item's innerText (trimmed), or `null` when no match.
*/
export function clickEddItemViaDispatchScript(itemName) {
return `(() => {
const edd = document.getElementById('editDropDown');
if (!edd || edd.offsetWidth === 0) return null;
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const target = ny(${JSON.stringify(itemName.toLowerCase())});
const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
function clickEl(el) {
const r = el.getBoundingClientRect();
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
el.dispatchEvent(new MouseEvent('mousedown', opts));
el.dispatchEvent(new MouseEvent('mouseup', opts));
el.dispatchEvent(new MouseEvent('click', opts));
return el.innerText.trim();
}
// Pass 1: exact match (prefer over partial)
for (const el of items) {
const t = ny((el.innerText?.trim() || '').toLowerCase());
if (t === target) return clickEl(el);
const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, '');
if (stripped === target) return clickEl(el);
}
// Pass 2: partial match
for (const el of items) {
const t = ny((el.innerText?.trim() || '').toLowerCase());
if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el);
}
return null;
})()`;
}
/**
* Click the "Показать все" / "Show all" link in the EDD footer via
* `dispatchEvent`. Tries `.eddBottom .hyperlink` first, then falls back
* to scanning for span/div/a with the literal text.
*
* Returns boolean — whether the link was found and clicked.
*/
export function clickShowAllInEddScript() {
return `(() => {
const edd = document.getElementById('editDropDown');
if (!edd || edd.offsetWidth === 0) return false;
let el = edd.querySelector('.eddBottom .hyperlink');
if (!el || el.offsetWidth === 0) {
const candidates = [...edd.querySelectorAll('span, div, a')]
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
el = candidates.find(e => {
const t = (e.innerText?.trim() || '').toLowerCase();
return t === 'показать все' || t === 'show all';
});
}
if (!el) return false;
const r = el.getBoundingClientRect();
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
el.dispatchEvent(new MouseEvent('mousedown', opts));
el.dispatchEvent(new MouseEvent('mouseup', opts));
el.dispatchEvent(new MouseEvent('click', opts));
return true;
})()`;
}
@@ -0,0 +1,53 @@
// web-test dom/edit-state v1.0 — focus and popup detection inside the 1C web client
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Is the currently focused element an INPUT (optionally TEXTAREA too)?
* Returns boolean.
*
* @param {object} [opts]
* @param {boolean} [opts.allowTextarea=false] — also return true for TEXTAREA.
*/
export function isInputFocusedScript({ allowTextarea = false } = {}) {
const cond = allowTextarea
? `f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'`
: `f.tagName === 'INPUT'`;
return `(() => {
const f = document.activeElement;
return !!(f && (${cond}));
})()`;
}
/**
* Is the currently focused INPUT/TEXTAREA inside a `.grid` ancestor?
* Used to verify grid edit-mode (active cell editor).
* Returns boolean.
*/
export function isInputFocusedInGridScript() {
return `(() => {
const f = document.activeElement;
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
let n = f;
while (n) {
if (n.classList?.contains('grid')) return true;
n = n.parentElement;
}
return false;
})()`;
}
/**
* Is a calculator (`.calculate`) or calendar (`.frameCalendar`) popup visible?
* Returns `'calculator' | 'calendar' | null`.
*
* For the "popup gone" check, callers use: `!await findOpenPopup()`.
*/
export function findOpenPopupScript() {
return `(() => {
const calc = document.querySelector('.calculate');
if (calc && calc.offsetWidth > 0) return 'calculator';
const cal = document.querySelector('.frameCalendar');
if (cal && cal.offsetWidth > 0) return 'calendar';
return null;
})()`;
}
@@ -0,0 +1,65 @@
// web-test dom/errors-stack v1.0 — DOM scripts for fetching error stack via OpenReport link.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Path-1 flow for platform exceptions: click "Сформировать отчет об ошибке" link,
// open detailed error dialog, read textarea, close cleanup dialogs.
/** Find OpenReport link coordinates on the error modal for given formNum. */
export function getOpenReportCoordsScript(formNum) {
return `(() => {
const el = document.getElementById('form${formNum}_OpenReport#text');
if (!el || el.offsetWidth <= 2) return null;
const rect = el.getBoundingClientRect();
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
})()`;
}
/** Check whether the "подробный текст ошибки" link is visible (signals report dialog ready). */
export function isErrorDetailLinkVisibleScript() {
return `(() => {
const links = document.querySelectorAll('a, [class*="hyper"], span');
for (const el of links) {
if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true;
}
return false;
})()`;
}
/** Read the largest visible non-empty textarea — contains the detailed error stack. */
export function readLargestVisibleTextareaScript() {
return `(() => {
let best = null;
document.querySelectorAll('textarea').forEach(ta => {
if (ta.offsetWidth > 0 && ta.value.length > 0) {
if (!best || ta.value.length > best.value.length) best = ta;
}
});
return best?.value || null;
})()`;
}
/** Click the OK button in the topmost cloud window (closes "Подробный текст ошибки"). */
export function clickTopCloudOkButtonScript() {
return `(() => {
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
.filter(w => w.offsetWidth > 0)
.sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0'));
for (const w of psWins) {
const ok = w.querySelector('button.webBtn, .pressDefault');
if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; }
}
return false;
})()`;
}
/** Click the × CloseButton in the topmost visible cloud window (closes "Отчет об ошибке"). */
export function clickReportCloseButtonScript() {
return `(() => {
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
.filter(w => w.offsetWidth > 0);
for (const w of psWins) {
const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; }
}
})()`;
}
@@ -0,0 +1,127 @@
// web-test dom/errors v1.0 — error/diagnostic detection (balloon, messages, modal, stateWindow)
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Check for validation errors / diagnostics after an action.
* Detects three patterns:
* 1. Inline balloon tooltip (div.balloon with .balloonMessage)
* 2. Messages panel (div.messages with msg0, msg1... grid rows)
* 3. Modal error dialog (high-numbered form with pressDefault + static texts)
* Returns { balloon, messages[], modal } or null if no errors.
*/
export function checkErrorsScript() {
return `(() => {
const result = {};
// 1. Inline balloon tooltip
const balloon = document.querySelector('.balloon');
if (balloon && balloon.offsetWidth > 0) {
const msg = balloon.querySelector('.balloonMessage');
const title = balloon.querySelector('.balloonTitle');
if (msg) {
result.balloon = {
title: title?.innerText?.trim() || 'Ошибка',
message: msg.innerText?.trim() || ''
};
// Count navigation arrows to indicate total errors
const fwd = balloon.querySelector('.balloonJumpFwd');
const back = balloon.querySelector('.balloonJumpBack');
const fwdDisabled = fwd?.classList.contains('disabled');
const backDisabled = back?.classList.contains('disabled');
if (fwd && !fwdDisabled) result.balloon.hasNext = true;
if (back && !backDisabled) result.balloon.hasPrev = true;
}
}
// 2. Messages panel (div.messages — pick visible one, multiple may exist across tabs)
const msgPanels = [...document.querySelectorAll('.messages')].filter(el => el.offsetWidth > 0);
for (const msgPanel of msgPanels) {
const msgs = [];
msgPanel.querySelectorAll('[id^="msg"]').forEach(line => {
if (line.offsetWidth === 0) return;
const textEl = line.querySelector('.gridBoxText');
const text = (textEl || line).innerText?.trim();
if (text) msgs.push(text);
});
if (msgs.length > 0) { result.messages = msgs; break; }
}
// 3+4. Modal dialogs: confirmation (multiple buttons) or error (single pressDefault)
// Uses form container ancestry to group buttons — pressButton elements often lack form-prefixed IDs
// Note: 1C shows some modals WITHOUT #modalSurface (e.g. "Не удалось записать" uses ps*win floating window)
// so we always scan for small forms with button patterns, regardless of modalSurface state
const formButtons = {};
[...document.querySelectorAll('a.press.pressButton')].forEach(btn => {
if (btn.offsetWidth === 0) return;
const container = btn.closest('[id$="_container"]');
const m = container?.id?.match(/^form(\\d+)_/);
if (!m) return;
const fn = m[1];
if (!formButtons[fn]) formButtons[fn] = [];
formButtons[fn].push(btn);
});
for (const [fn, buttons] of Object.entries(formButtons)) {
const p = 'form' + fn + '_';
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
if (elCount > 100) continue; // Skip large content forms
if (buttons.length > 1) {
// Confirmation dialog (multiple buttons: Да/Нет, OK/Отмена, etc.)
// Must have a Message element — real 1C confirmations always have form{N}_Message.
// Without it, this is just a regular form with multiple buttons (e.g. EPF form).
const msgEl = document.getElementById(p + 'Message');
if (!msgEl || msgEl.offsetWidth === 0) continue;
const message = msgEl.innerText?.trim() || '';
const btnNames = buttons.map(el => {
const b = { name: el.innerText?.trim() || '' };
if (el.classList.contains('pressDefault')) b.default = true;
return b;
}).filter(b => b.name);
result.confirmation = { message, buttons: btnNames.map(b => b.name), formNum: parseInt(fn) };
break;
}
}
// Single-button modal: error dialog with pressDefault + staticText
// Skip forms with input fields — those are data entry forms (e.g. register record),
// not error dialogs. Real error modals only have staticText + buttons.
if (!result.confirmation) {
for (const [fn, buttons] of Object.entries(formButtons)) {
const p = 'form' + fn + '_';
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
if (elCount > 100) continue;
if (buttons.length !== 1 || !buttons[0].classList.contains('pressDefault')) continue;
const hasInputs = document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').length > 0;
if (hasInputs) continue;
const texts = [...document.querySelectorAll('[id^="' + p + '"].staticText')]
.filter(el => el.offsetWidth > 0)
.map(el => el.innerText?.trim())
.filter(Boolean);
if (texts.length > 0) {
result.modal = { message: texts.join(' '), formNum: parseInt(fn), button: buttons[0].innerText?.trim() || '' };
// Check if OpenReport link is available (platform exceptions have visible link text)
const reportLink = document.getElementById(p + 'OpenReport#text');
if (reportLink && reportLink.offsetWidth > 2 && reportLink.textContent.trim()) {
result.modal.hasReport = true;
}
// Grab AdditionalInfo/ServerText if filled (may contain extra error details)
const addInfo = document.getElementById(p + 'AdditionalInfo');
if (addInfo && addInfo.textContent && addInfo.textContent.trim()) result.modal.additionalInfo = addInfo.textContent.trim();
const srvText = document.getElementById(p + 'ServerText');
if (srvText && srvText.textContent && srvText.textContent.trim()) result.modal.serverText = srvText.textContent.trim();
break;
}
}
}
// 5. SpreadsheetDocument state window (info bar inside moxelContainer)
// Shows messages like "Не установлено значение параметра X" or "Отчет не сформирован"
const stateWins = [...document.querySelectorAll('.stateWindowSupportSurface')].filter(el => el.offsetWidth > 0);
if (stateWins.length) {
const texts = stateWins.map(el => el.innerText?.trim()).filter(Boolean);
if (texts.length) result.stateText = texts;
}
return (result.balloon || result.messages || result.modal || result.confirmation || result.stateText) ? result : null;
})()`;
}
@@ -0,0 +1,187 @@
// web-test dom/filter v1.0 — DOM scripts for filterList / unfilterList
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Find the first grid cell on the form and return its center coords.
* Used as a fallback target for Alt+F when there's no search input.
*
* Returns `{ x, y } | null`.
*/
export function findFirstGridCellCoordsScript(formNum) {
return `(() => {
const p = 'form${formNum}_';
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0);
if (!grid) return null;
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
if (!rows.length) return null;
const cells = [...rows[0].querySelectorAll('.gridBox')];
if (!cells.length) return null;
const r = cells[0].getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Find the grid cell of the first row in the column whose header text matches `field`
* (fuzzy: exact → startsWith → includes; normalizes ё/е and NBSP).
*
* If the column isn't in the grid, returns coords of the first cell + `needDlb: true`
* so the caller can use DLB to switch FieldSelector after opening the dialog.
*
* Returns:
* - `{ x, y, needDlb? } ` — coords to click (advanced search target)
* - `{ error }` — `'no_grid' | 'no_rows' | 'no_cells' | 'cell_not_found'`
*/
export function findColumnFirstCellCoordsScript(formNum, field) {
return `(() => {
const p = 'form${formNum}_';
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0);
if (!grid) return { error: 'no_grid' };
const targetField = ${JSON.stringify(field)};
const headers = [...grid.querySelectorAll('.gridHead .gridBox')];
let colIndex = -1;
let startsWithIdx = -1;
let includesIdx = -1;
for (let i = 0; i < headers.length; i++) {
const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' ');
if (!t) continue;
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase());
if (tl === fl) { colIndex = i; break; }
if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; }
else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; }
}
if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx;
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
if (!rows.length) return { error: 'no_rows' };
if (colIndex < 0) {
const cells = [...rows[0].querySelectorAll('.gridBox')];
if (!cells.length) return { error: 'no_cells' };
const r = cells[0].getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true };
}
const cells = [...rows[0].querySelectorAll('.gridBox')];
if (colIndex >= cells.length) return { error: 'cell_not_found' };
const r = cells[colIndex].getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Read FieldSelector input + its DLB button coords on the advanced search dialog.
* Returns `{ current, dlbX, dlbY }` (zero coords if DLB not visible).
*/
export function readFieldSelectorInfoScript(dialogForm) {
return `(() => {
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
const dlb = document.getElementById(p + 'FieldSelector_DLB');
return {
current: fsInput?.value?.trim() || '',
dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0,
dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0
};
})()`;
}
/**
* Pick a field name in the FieldSelector EDD dropdown (fuzzy: exact → includes,
* normalizes ё/е and NBSP).
*
* Returns:
* - `{ x, y, name }` — coords + matched name to click
* - `{ error, available? }` — `'no_dropdown'` or `'field_not_found'` with list of available names
*/
export function pickFieldInSelectorDropdownScript(field) {
return `(() => {
const edd = document.getElementById('editDropDown');
if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' };
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const target = ny(${JSON.stringify(field.toLowerCase())});
const items = [...edd.querySelectorAll('div')].filter(el =>
el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n'));
const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target)
|| items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target));
if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) };
const r = match.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() };
})()`;
}
/**
* Read advanced search dialog state — FieldSelector value, Pattern input id+value,
* and field type flags (isDate via iCalendB button, isRef via iDLB button on Pattern).
*
* Returns `{ fieldSelector, patternValue, patternId, isDate, isRef }`.
*/
export function readFilterDialogInfoScript(dialogForm) {
return `(() => {
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
const ptLabel = ptInput?.closest('label');
const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : [];
const isDate = btns.some(c => c.includes('iCalendB'));
const isRef = !isDate && btns.some(c => c.includes('iDLB'));
return {
fieldSelector: fsInput?.value?.trim() || '',
patternValue: ptInput?.value?.trim() || '',
patternId: ptInput?.id || '',
isDate,
isRef
};
})()`;
}
/**
* Find the × close button on the filter badge whose title matches `field`
* (exact → includes; normalizes ё/е and NBSP).
*
* Returns:
* - `{ x, y, field }` — coords + actual field title from the badge
* - `{ error, available }` — `'not_found'` with list of available badge titles
*/
export function findFilterBadgeCloseScript(formNum, field) {
return `(() => {
const p = 'form${formNum}_';
const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || '';
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const target = ny(${JSON.stringify(field.toLowerCase())});
const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0);
for (const item of items) {
const titleEl = item.querySelector('.trainName');
const title = ny(norm(titleEl?.innerText).toLowerCase());
if (title === target || title.includes(target)) {
const close = item.querySelector('.trainClose');
if (close) {
const r = close.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) };
}
}
}
const available = items.map(item => norm(item.querySelector('.trainName')?.innerText));
return { error: 'not_found', available };
})()`;
}
/**
* Find the × close button on the FIRST visible filter badge (for clear-all loop).
* Returns `{ x, y } | null`.
*/
export function findFirstFilterBadgeCloseScript(formNum) {
return `(() => {
const p = 'form${formNum}_';
const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')]
.find(el => el.offsetWidth > 0);
if (!item) return null;
const close = item.querySelector('.trainClose');
if (!close) return null;
const r = close.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
@@ -0,0 +1,34 @@
// web-test dom/form-state v1.0 — combined detectForm + readForm + open tabs
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN } from './_shared.mjs';
/**
* Combined: detect form + read form + read open tabs.
* Single evaluate call instead of 3. Used by browser.getFormState().
*/
export function getFormStateScript() {
return `(() => {
${DETECT_FORM_FN}
${DETECT_FORMS_FN}
${READ_FORM_FN}
const formNum = detectForm();
const meta = detectForms();
if (formNum === null) return { form: null, formCount: 0, message: 'No form detected' };
const p = 'form' + formNum + '_';
const formData = readForm(p);
// Open tabs bar (present only when tab panel is enabled in 1C settings)
const openTabs = [];
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
const text = el.innerText?.trim();
if (!text) return;
const entry = { name: text };
if (el.classList.contains('select')) entry.active = true;
openTabs.push(entry);
});
const activeTab = openTabs.find(t => t.active)?.name || null;
const result = { form: formNum, activeTab, openForms: meta.allForms, formCount: meta.formCount, ...formData };
if (meta.modal) result.modal = true;
if (openTabs.length) result.openTabs = openTabs;
return result;
})()`;
}
@@ -0,0 +1,617 @@
// web-test dom/forms v1.4 — form detection, content read, click-target/field-button resolution
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { DETECT_FORM_FN, READ_FORM_FN } from './_shared.mjs';
/**
* Detect the active form number.
* Picks the form with the most visible elements (excluding form0 = home page).
*/
export function detectFormScript() {
return `(() => {
${DETECT_FORM_FN}
return detectForm();
})()`;
}
/**
* Read full form state for a given form number.
* Uses shared READ_FORM_FN.
*/
export function readFormScript(formNum) {
const p = `form${formNum}_`;
return `(() => {
${READ_FORM_FN}
return readForm(${JSON.stringify(p)});
})()`;
}
/**
* Find a clickable element on the current form (button, hyperlink, tab, frame button).
* Returns { id, kind, name } for Playwright page.click(), or { error, available }.
* Supports synonym matching: visible text AND internal name from DOM ID.
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
*/
export function findClickTargetScript(formNum, text, { tableName, gridSelector } = {}) {
const p = `form${formNum}_`;
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
const p = ${JSON.stringify(p)};
const tableName = ${JSON.stringify(tableName || '')};
const gridSelector = ${JSON.stringify(gridSelector || '')};
const items = [];
// Buttons (a.press)
[...document.querySelectorAll('a.press[id^="' + p + '"]')].filter(el => el.offsetWidth > 0).forEach(el => {
const idName = el.id.replace(p, '');
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
const span = el.querySelector('.submenuText') || el.querySelector('span');
const text = norm(span?.textContent) || norm(el.innerText);
if (!text && !el.classList.contains('pressCommand')) return;
const isSubmenu = /^(?:Подменю|allActions)/i.test(idName);
const item = { id: el.id, name: text || idName, label: idName, kind: isSubmenu ? 'submenu' : 'button' };
// Icon-only buttons: use tooltip for fuzzy match (1C puts title on parent .framePress)
if (!text) { const tip = norm(el.title || el.parentElement?.title || ''); if (tip) item.tooltip = tip; }
items.push(item);
});
// Hyperlinks (staticTextHyper)
[...document.querySelectorAll('[id^="' + p + '"].staticTextHyper')].filter(el => el.offsetWidth > 0).forEach(el => {
const idName = el.id.replace(p, '');
const text = norm(el.innerText);
items.push({ id: el.id, name: text, label: idName, kind: 'hyperlink' });
});
// Frame buttons
[...document.querySelectorAll('[id^="' + p + '"] .frameButton, [id^="' + p + '"].frameButton')].filter(el => el.offsetWidth > 0).forEach(el => {
const text = norm(el.innerText);
const idName = el.id.replace(p, '');
if (!text && !idName) return;
items.push({ id: el.id, name: text || idName, label: text ? '' : idName, kind: 'frameButton' });
});
// Tumbler items (toggle switch segments)
[...document.querySelectorAll('[id^="' + p + '"].tumblerItem')].filter(el => el.offsetWidth > 0).forEach(el => {
const idName = el.id.replace(p, '');
const text = norm(el.innerText);
items.push({ id: el.id, name: text || idName, label: idName, kind: 'tumbler' });
});
// Checkboxes (div.checkbox) — match by label or internal name
[...document.querySelectorAll('[id^="' + p + '"].checkbox')].filter(el => el.offsetWidth > 0).forEach(el => {
const idName = el.id.replace(p, '');
const titleEl = document.getElementById(p + idName + '#title_text');
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
items.push({ id: el.id, name: label || idName, label: idName, kind: 'checkbox' });
});
// Tabs (scoped to form)
[...document.querySelectorAll('[data-content]')].filter(el => {
if (el.offsetWidth === 0) return false;
let node = el.parentElement;
while (node) {
if (node.id && node.id.startsWith(p)) return true;
node = node.parentElement;
}
return false;
}).forEach(el => {
const r = el.getBoundingClientRect();
items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
});
// Navigation panel items (FormNavigationPanel) — in parent page{N}
const formEl = document.querySelector('[id^="' + p + '"]');
if (formEl) {
let pageEl = formEl.parentElement;
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
if (pageEl) {
pageEl.querySelectorAll('.navigationItem').forEach(el => {
if (el.offsetWidth === 0) return;
const nameEl = el.querySelector('.navigationItemName');
const text = norm(nameEl?.innerText || '');
if (!text) return;
items.push({ id: el.id, name: text, label: '', kind: 'navigation' });
});
}
}
// When table is specified, scope button search to grid's parent container
if (gridSelector) {
const gridEl = document.querySelector(gridSelector);
if (gridEl) {
// Find parent container that has id with formPrefix and contains the grid
let container = gridEl.parentElement;
while (container && container !== document.body) {
if (container.id && container.id.startsWith(p)) break;
container = container.parentElement;
}
// Filter items to those inside the container
const containerItems = container && container !== document.body
? items.filter(i => { const el = document.getElementById(i.id); return el && container.contains(el); })
: [];
// Try fuzzy match within container first
let cf = containerItems.find(i => i.name.toLowerCase() === target);
if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase() === target);
if (!cf && target.length >= 4) cf = containerItems.find(i => i.name.toLowerCase().includes(target));
if (!cf && target.length >= 4) cf = containerItems.find(i => i.label && i.label.toLowerCase().includes(target));
if (cf) { const res = { id: cf.id, kind: cf.kind, name: cf.name }; if (cf.x != null) { res.x = cf.x; res.y = cf.y; } return res; }
// Fallback: filter by gridName id-prefix (e.g. ИсходящиеКоманднаяПанель_Добавить)
const gridName = gridEl.id ? gridEl.id.replace(p, '') : '';
if (gridName) {
const prefixItems = items.filter(i => i.label && i.label.includes(gridName));
let pf = prefixItems.find(i => i.name.toLowerCase() === target);
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.label && i.label.toLowerCase().includes(target));
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.name.toLowerCase().includes(target));
if (pf) { const res = { id: pf.id, kind: pf.kind, name: pf.name }; if (pf.x != null) { res.x = pf.x; res.y = pf.y; } return res; }
}
}
// Fall through to unscoped search
}
// Fuzzy match: exact name -> exact label -> exact tooltip -> startsWith name -> startsWith label -> includes name -> includes label -> includes tooltip
// Skip includes() for short strings (< 4 chars) to avoid false positives
// e.g. "Да" matching "КомандаУстановитьВсе"
let found = items.find(i => i.name.toLowerCase() === target);
if (!found) found = items.find(i => i.label && i.label.toLowerCase() === target);
if (!found) found = items.find(i => i.tooltip && i.tooltip.toLowerCase() === target);
if (!found) found = items.find(i => i.name.toLowerCase().startsWith(target));
if (!found) found = items.find(i => i.label && i.label.toLowerCase().startsWith(target));
if (!found && target.length >= 4) found = items.find(i => i.name.toLowerCase().includes(target));
if (!found && target.length >= 4) found = items.find(i => i.label && i.label.toLowerCase().includes(target));
if (!found && target.length >= 4) found = items.find(i => i.tooltip && i.tooltip.toLowerCase().includes(target));
if (found) {
const res = { id: found.id, kind: found.kind, name: found.name };
if (found.x != null) { res.x = found.x; res.y = found.y; }
return res;
}
// Grid rows — fallback: search in table rows (for hierarchical/tree navigation)
// Search ALL visible grids (or specific grid when table parameter is set)
let grids;
if (gridSelector) {
const g = document.querySelector(gridSelector);
grids = g ? [g] : [];
} else {
grids = [...document.querySelectorAll('[id^="' + p + '"].grid')].filter(g => g.offsetWidth > 0);
}
for (const grid of grids) {
const body = grid.querySelector('.gridBody');
if (!body) continue;
const lines = [...body.querySelectorAll('.gridLine')];
for (const line of lines) {
const textBoxes = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
const rowTexts = textBoxes.map(b => norm(b.innerText) || '').filter(Boolean);
const firstCell = rowTexts[0]?.toLowerCase() || '';
const rowText = rowTexts.join(' ').toLowerCase();
if (firstCell === target || rowText === target || (target.length >= 4 && (firstCell.includes(target) || rowText.includes(target)))) {
const imgBox = line.querySelector('.gridBoxImg');
const isGroup = imgBox?.querySelector('.gridListH') !== null;
const isParent = imgBox?.querySelector('.gridListV') !== null;
const isTreeNode = line.querySelector('.gridBoxTree') !== null;
const hasChildren = line.querySelector('[tree="true"]') !== null;
let kind;
if (isGroup) kind = 'gridGroup';
else if (isParent) kind = 'gridParent';
else if (isTreeNode && hasChildren) kind = 'gridTreeNode';
else kind = 'gridRow';
const r = line.getBoundingClientRect();
return { id: '', kind, name: rowTexts[0] || '', gridId: grid.id,
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
}
}
}
return { error: 'not_found', available: items.map(i => i.tooltip ? i.name + ' [' + i.tooltip + ']' : i.name).filter(Boolean) };
})()`;
}
/**
* Find a field's action button (DLB, OB, CLR, CB) by fuzzy field name.
* Returns { fieldName, buttonId, buttonType } or { error, available }.
*/
export function findFieldButtonScript(formNum, fieldName, buttonSuffix = 'DLB') {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const target = ${JSON.stringify(fieldName.toLowerCase().replace(/ё/g, 'е'))};
const suffix = ${JSON.stringify(buttonSuffix)};
const allFields = [];
document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
const titleEl = document.getElementById(p + name + '#title_text')
|| document.getElementById(p + name + '#title_div');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
allFields.push({ name, label });
});
// Also collect checkboxes for DCS pair matching
const allCheckboxes = [];
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '');
const titleEl = document.getElementById(p + name + '#title_text');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
allCheckboxes.push({ inputId: el.id, name, label });
});
// Build DCS pairs: checkbox label → paired value field
const dcsPairs = {};
for (const f of [...allFields, ...allCheckboxes]) {
const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/);
if (!m) continue;
if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {};
dcsPairs[m[1]][m[2]] = f;
}
let found = allFields.find(f => f.name.toLowerCase() === target);
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target);
if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target));
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target));
// DCS pair: match checkbox or value label → resolve to paired value field
let dcsCheckbox = null;
if (!found) {
for (const pair of Object.values(dcsPairs)) {
const cb = pair['Использование'];
const val = pair['Значение'];
if (!cb || !val) continue;
const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase();
if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) {
found = val;
dcsCheckbox = cb;
break;
}
}
}
if (!found) {
return { error: 'field_not_found', available: allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name) };
}
const btnId = p + found.name + '_' + suffix;
const btn = document.getElementById(btnId);
if (!btn || btn.offsetWidth === 0) {
return { error: 'button_not_found', fieldName: found.name, message: suffix + ' button not visible for field ' + found.name };
}
const result = { fieldName: found.name, buttonId: btnId, buttonType: suffix };
if (dcsCheckbox) result.dcsCheckbox = { inputId: dcsCheckbox.inputId };
return result;
})()`;
}
/**
* Resolve field names to element IDs for Playwright page.fill().
* Returns [{ field, inputId, name, label }] or [{ field, error, available }].
* Supports synonym matching: internal name AND visible label.
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
*/
export function resolveFieldsScript(formNum, fields) {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const fieldNames = ${JSON.stringify(Object.keys(fields))};
const results = [];
// Build field map with name + label for synonym matching
const allFields = [];
document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
const titleEl = document.getElementById(p + name + '#title_text')
|| document.getElementById(p + name + '#title_div');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
const last = { inputId: el.id, name, label };
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) last.hasSelect = true;
const cbEl = document.getElementById(p + name + '_CB');
if (cbEl?.offsetWidth > 0) {
last.hasPick = true;
if (cbEl.classList.contains('iCalendB')) last.isDate = true;
}
allFields.push(last);
});
// Checkboxes
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '');
const titleEl = document.getElementById(p + name + '#title_text');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
const checked = el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select');
allFields.push({ inputId: el.id, name, label, isCheckbox: true, checked });
});
// Radio button groups — base element = option 0, others are #N#radio
const radioSeen = new Set();
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
if (el.offsetWidth === 0) return;
const id = el.id.replace(p, '');
// Skip if already processed or if it's a sub-element (#N#radio)
const m = id.match(/^(.+?)#(\\d+)#radio$/);
const groupName = m ? m[1] : (!id.includes('#') ? id : null);
if (!groupName || radioSeen.has(groupName)) return;
radioSeen.add(groupName);
const titleEl = document.getElementById(p + groupName + '#title_text');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
// Collect options: option 0 is the base element, options 1+ have #N#radio
const options = [];
// Option 0: base element
const base = document.getElementById(p + groupName);
if (base && base.classList.contains('radio') && base.offsetWidth > 0) {
const textEl = document.getElementById(p + groupName + '#0#radio_text');
options.push({ index: 0, label: textEl?.innerText?.trim() || '', selected: base.classList.contains('select') });
}
// Options 1+
for (let i = 1; i < 20; i++) {
const opt = document.getElementById(p + groupName + '#' + i + '#radio');
if (!opt || opt.offsetWidth === 0) break;
const textEl = document.getElementById(p + groupName + '#' + i + '#radio_text');
options.push({ index: i, label: textEl?.innerText?.trim() || '', selected: opt.classList.contains('select') });
}
allFields.push({ inputId: p + groupName, name: groupName, label, isRadio: true, options });
});
// Build DCS pairs: checkbox label → paired value field
const dcsPairs = {};
for (const f of allFields) {
const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/);
if (!m) continue;
if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {};
dcsPairs[m[1]][m[2]] = f;
}
for (const fieldName of fieldNames) {
const target = fieldName.toLowerCase().replace(/\\n/g, ' ').replace(/:$/, '');
// Fuzzy: exact name -> exact label -> includes name -> includes label
let found = allFields.find(f => f.name.toLowerCase() === target);
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target);
if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target));
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target));
// DCS pair: match checkbox or value label → resolve to paired value field
if (!found) {
for (const pair of Object.values(dcsPairs)) {
const cb = pair['Использование'];
const val = pair['Значение'];
if (!cb || !val) continue;
const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase();
if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) {
found = val;
found._dcsCheckbox = cb;
break;
}
}
}
if (found) {
const entry = { field: fieldName, inputId: found.inputId, name: found.name, label: found.label };
if (found.isCheckbox) { entry.isCheckbox = true; entry.checked = found.checked; }
if (found.isRadio) { entry.isRadio = true; entry.options = found.options; }
if (found.hasSelect) entry.hasSelect = true;
if (found.hasPick) entry.hasPick = true;
if (found.isDate) entry.isDate = true;
if (found._dcsCheckbox) {
entry.dcsCheckbox = { inputId: found._dcsCheckbox.inputId, checked: found._dcsCheckbox.checked };
delete found._dcsCheckbox;
}
results.push(entry);
} else {
const available = allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name);
results.push({ field: fieldName, error: 'not_found', available });
}
}
return results;
})()`;
}
/**
* Detect a new form opened above `prevFormNum`. Two modes:
* default (broad) — counts any visible `[id]` element; finds dialogs whose
* `a.press` buttons have empty IDs. Used by selectValue / fillTableRow.
* `{ strict: true }` — only counts visible interactive elements
* (`input.editInput[id], a.press[id]`); used by fillReferenceField.
*
* Returns the highest new form number or `null`.
*/
export function detectNewFormScript(prevFormNum, { strict = false } = {}) {
const selector = strict ? 'input.editInput[id], a.press[id]' : '[id]';
const visibleCheck = strict
? 'el.offsetWidth === 0'
: 'el.offsetWidth === 0 && el.offsetHeight === 0';
return `(() => {
const forms = {};
document.querySelectorAll(${JSON.stringify(selector)}).forEach(el => {
if (${visibleCheck}) return;
const m = el.id.match(/^form(\\d+)_/);
if (m) forms[m[1]] = true;
});
const nums = Object.keys(forms).map(Number).filter(n => n > ${prevFormNum});
return nums.length > 0 ? Math.max(...nums) : null;
})()`;
}
/**
* Find the search input on a list form (matches `SearchString` / `ПоискаСтроки` id).
* Returns `{ id, value } | null`.
*/
export function findSearchInputScript(formNum) {
return `(() => {
const p = 'form${formNum}_';
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
return el ? { id: el.id, value: el.value || '' } : null;
})()`;
}
/**
* Find a visible `a.press` button by its exact innerText (after trim).
* Returns `{ x, y } | null` for `page.mouse.click(x, y)`.
*
* Used for modal dialog buttons (Найти, OK) where page.click may be blocked.
*/
export function findNamedButtonScript(buttonText) {
return `(() => {
const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0);
const btn = btns.find(el => el.innerText?.trim() === ${JSON.stringify(buttonText)});
if (!btn) return null;
const r = btn.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Find a CompareType radio button by index (1 = "contains", 2 = "exact", etc.)
* on a search/filter dialog.
*
* Returns:
* - `{ already: true }` — the group is disabled OR the radio is already selected
* - `{ x, y } | null` — coords to click, or null if radio not present
*/
export function findCompareTypeRadioScript(dialogForm, radioIndex) {
return `(() => {
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
const group = document.getElementById(p + 'CompareType');
if (group && group.classList.contains('disabled')) return { already: true };
const el = document.getElementById(p + 'CompareType#' + ${JSON.stringify(String(radioIndex))} + '#radio');
if (!el || el.offsetWidth === 0) return null;
if (el.classList.contains('select')) return { already: true };
const r = el.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Is any element of `form{dialogForm}_` currently visible?
* Used to poll dialog dismissal after Escape.
*/
export function isFormVisibleScript(dialogForm) {
return `(() => {
const p = 'form${dialogForm}_';
return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0);
})()`;
}
/**
* Find the Pattern input id on a search/filter dialog. Returns `id | null`.
*/
export function findPatternInputIdScript(dialogForm) {
return `(() => {
const p = 'form${dialogForm}_';
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
return el ? el.id : null;
})()`;
}
/**
* Is the given form a type selection dialog ("Выбор типа данных")?
*
* Detection signals (any one is sufficient):
* - `form{N}_OK` element exists (selection forms use "Выбрать", not "OK")
* - `form{N}_ValueList` grid exists (specific to type/value list dialogs)
* - window title contains "Выбор типа" on a visible `.toplineBoxTitle`
*
* Returns boolean.
*/
export function isTypeDialogScript(formNum) {
return `(() => {
const p = 'form' + ${formNum} + '_';
const hasOK = !!document.getElementById(p + 'OK');
const hasValueList = !!document.getElementById(p + 'ValueList');
const hasTitle = [...document.querySelectorAll('.toplineBoxTitle')]
.some(el => el.offsetWidth > 0 && /выбор типа/i.test(el.getAttribute('title') || ''));
return hasOK || hasValueList || hasTitle;
})()`;
}
/**
* Click the "Показать все" / "Show all" link inside the "нет в списке"
* cloud popup via `dispatchEvent`. Returns boolean — whether clicked.
*/
export function clickShowAllInNotInListCloudScript() {
return `(() => {
for (const el of document.querySelectorAll('div')) {
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
const s = getComputedStyle(el);
if (s.position !== 'absolute' && s.position !== 'fixed') continue;
if ((parseInt(s.zIndex) || 0) < 100) continue;
if (!(el.innerText || '').includes('нет в списке')) continue;
const links = [...el.querySelectorAll('a, span, div')]
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
const showAll = links.find(e => {
const t = (e.innerText?.trim() || '').toLowerCase();
return t === 'показать все' || t === 'show all';
});
if (showAll) {
const r = showAll.getBoundingClientRect();
const opts = { bubbles:true, cancelable:true,
clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
showAll.dispatchEvent(new MouseEvent('mousedown', opts));
showAll.dispatchEvent(new MouseEvent('mouseup', opts));
showAll.dispatchEvent(new MouseEvent('click', opts));
return true;
}
return false;
}
return false;
})()`;
}
/**
* Is the "нет в списке" cloud popup visible? 1C shows it as a positioned div
* (absolute/fixed, high z-index) whose text contains "нет в списке".
* Returns boolean.
*/
export function isNotInListCloudVisibleScript() {
return `(() => {
const divs = document.querySelectorAll('div');
for (const el of divs) {
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
const style = getComputedStyle(el);
if (style.position !== 'absolute' && style.position !== 'fixed') continue;
const z = parseInt(style.zIndex) || 0;
if (z < 100) continue;
if ((el.innerText || '').includes('нет в списке')) return true;
}
return false;
})()`;
}
/**
* Find a child form opened above `prevFormNum` whose `form{N}_{buttonName}` button is visible.
* Used by type-dialog Ctrl+F flow to locate the "Найти" sub-dialog form number.
* Returns the form number or `null`.
*/
export function findChildFormByButtonScript(prevFormNum, buttonName, range = 20) {
return `(() => {
for (let n = ${prevFormNum} + 1; n < ${prevFormNum} + ${range}; n++) {
const btn = document.getElementById('form' + n + '_' + ${JSON.stringify(buttonName)});
if (btn && btn.offsetWidth > 0) return n;
}
return null;
})()`;
}
/**
* Read visible rows of a type-dialog ValueList grid and return rows that fuzzy-match `typeNorm`.
*
* `typeNorm` should already be lowercased, NBSP-normalized, ё→е normalized (use `normYo`).
*
* Returns `{ visible: string[], matches: Array<{ text, x, y }> }`.
*/
export function readTypeDialogVisibleRowsScript(formNum, typeNorm) {
return `(() => {
const grid = document.getElementById('form${formNum}_ValueList');
if (!grid) return { visible: [], matches: [] };
const body = grid.querySelector('.gridBody');
if (!body) return { visible: [], matches: [] };
const lines = body.querySelectorAll('.gridLine');
const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim();
const typeNorm = ${JSON.stringify(typeNorm)};
const visible = [];
const matches = [];
for (const line of lines) {
const text = norm(line.innerText);
if (!text) continue;
visible.push(text);
if (text.toLowerCase().replace(/ё/gi, 'е').includes(typeNorm)) {
const r = line.getBoundingClientRect();
matches.push({ text, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
}
}
return { visible, matches };
})()`;
}
@@ -0,0 +1,280 @@
// web-test dom/grid-edit v1.0 — DOM scripts for row-fill (grid edit-time operations)
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// All helpers below accept an optional `gridSelector`. When passed, they target
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
// the page (this matches the implicit "current grid" used by row-fill).
/** Inline JS fragment that resolves the target grid into `const grid`. */
function gridResolver(gridSelector) {
return gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
}
/**
* Read the grid's column header texts paired with their `colindex` attribute,
* fuzzy-match `fieldKeys` (lowercased) against them, and return the keys in
* left-to-right colindex order.
*
* Keys that don't match a column get colindex `999` (pushed to the end);
* caller is expected to preserve their original relative order.
*
* Returns `string[] | null` (null when no grid or no head).
*/
export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const head = grid.querySelector('.gridHead');
if (!head) return null;
const headLine = head.querySelector('.gridLine') || head;
const cols = [];
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase();
const ci = parseInt(box.getAttribute('colindex') || '-1');
if (t) cols.push({ text: t, colindex: ci });
});
const keys = ${JSON.stringify(fieldKeys)};
const mapped = keys.map(k => {
const exact = cols.find(c => c.text === k);
if (exact) return { key: k, colindex: exact.colindex };
const inc = cols.find(c => c.text.includes(k) || k.includes(c.text));
return { key: k, colindex: inc ? inc.colindex : 999 };
});
mapped.sort((a, b) => a.colindex - b.colindex);
return mapped.map(m => m.key);
})()`;
}
/**
* Resolve cell coords for row `row` by matching the first column whose header
* fuzzy-matches any of `fieldKeys` (lowercased). Falls back to the second
* visible (non-`.gridBoxComp`) box when no header matches.
*
* Returns one of:
* - `{ x, y, currentText }` — coords + cell text
* - `{ error: 'no_grid' | 'no_grid_body' | 'no_cell' }`
* - `{ error: 'row_out_of_range', total }`
*/
export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return { error: 'no_grid' };
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
if (!head || !body) return { error: 'no_grid_body' };
// Read column headers to find target colindex
const headLine = head.querySelector('.gridLine') || head;
const cols = [];
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const t = box.querySelector('.gridBoxText');
const ci = box.getAttribute('colindex');
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
});
const keys = ${JSON.stringify(fieldKeys)};
let targetColindex = null;
for (const key of keys) {
const exact = cols.find(c => c.text === key);
if (exact) { targetColindex = exact.colindex; break; }
const inc = cols.find(c => c.text.includes(key) || key.includes(c.text));
if (inc) { targetColindex = inc.colindex; break; }
}
const rows = [...body.querySelectorAll('.gridLine')];
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
const line = rows[${row}];
// Find body cell by colindex (reliable across merged headers)
let box = null;
if (targetColindex != null) {
box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
}
// Fallback: second visible box (skip checkbox/N column)
if (!box) {
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
box = boxes.length > 1 ? boxes[1] : boxes[0];
}
if (!box) return { error: 'no_cell' };
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
const cell = box.querySelector('.gridBoxText') || box;
const r = cell.getBoundingClientRect();
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
})()`;
}
/**
* Like `findCellCoordsByFieldsScript` but for a SINGLE key, with extra
* "no-space/no-dash" fuzzy fallback (e.g. "Группа Контрагентов" header matches
* key "ГруппаКонтрагентов").
*
* Returns `{ x, y, currentText } | null`.
*/
export function findNextCellCoordsByKeyScript(gridSelector, row, key) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
if (!head || !body) return null;
const headLine = head.querySelector('.gridLine') || head;
const cols = [];
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const t = box.querySelector('.gridBoxText');
const ci = box.getAttribute('colindex');
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
});
const kl = ${JSON.stringify(key.toLowerCase())};
const klNoSpace = kl.replace(/[\\s\\-]+/g, '');
let targetColindex = null;
const exact = cols.find(c => c.text === kl);
if (exact) targetColindex = exact.colindex;
else {
const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text)
|| c.text.includes(klNoSpace) || klNoSpace.includes(c.text));
if (inc) targetColindex = inc.colindex;
}
if (targetColindex == null) return null;
const rows = [...body.querySelectorAll('.gridLine')];
if (${row} >= rows.length) return null;
const line = rows[${row}];
const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
if (!box) return null;
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
const cell = box.querySelector('.gridBoxText') || box;
const r = cell.getBoundingClientRect();
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
})()`;
}
/**
* Inspect the element at point `(x, y)`. If it's inside a `.gridBox` containing
* a `.checkbox`, return `{ checked, x, y }` (coords of the checkbox center for
* direct click).
*
* Returns `null` when there's no cell, or the cell isn't a checkbox cell.
*/
export function findCheckboxAtPointScript(x, y) {
return `(() => {
const el = document.elementFromPoint(${x}, ${y});
const cell = el?.closest('.gridBox');
if (!cell) return null;
const chk = cell.querySelector('.checkbox');
if (!chk) return null;
const r = chk.getBoundingClientRect();
return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
})()`;
}
/**
* Find center coords of the first VISIBLE non-`.gridBoxComp` cell on a row
* OTHER than `row` (used to commit an edit by clicking off the edited row —
* Escape would cancel in tree grids).
*
* For `row === 0`, targets row 1; otherwise targets row 0.
*
* Returns `{ x, y } | null` (null when there's no other row).
*/
export function findRowCommitClickCoordsScript(gridSelector, row) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const body = grid.querySelector('.gridBody');
if (!body) return null;
const rows = [...body.querySelectorAll('.gridLine')];
const otherIdx = ${row} === 0 ? 1 : 0;
const other = rows[otherIdx];
if (!other) return null;
const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0];
if (!box) return null;
const r = box.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Diagnostic: are we in grid edit mode (active INPUT inside `.grid` or
* `.gridContent`)? Returns an OBJECT (not a boolean) suitable for diagnostics:
* - `{ inEdit: true }` — good
* - `{ inEdit: false, tag: 'DIV' }` — active element wasn't INPUT
* - `{ inEdit: false, hint: 'input not inside grid' }` — input but no grid ancestor
*/
export function getGridEditCheckScript() {
return `(() => {
const f = document.activeElement;
if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName };
let node = f;
while (node) {
if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true };
node = node.parentElement;
}
return { inEdit: false, hint: 'input not inside grid' };
})()`;
}
/**
* Read the currently focused element if it's an editable grid cell (INPUT or
* TEXTAREA inside `.grid` / `.gridContent`). Resolves the header text by
* matching x-overlap of the input's bounding rect against header boxes.
*
* Returns one of:
* - `{ tag: 'INPUT', id, fullName, headerText }` — editable cell
* - `{ tag: 'DIV' | 'BODY' | ... }` — focused but not an editable cell
* - `{ tag: 'none' }` — nothing focused
*
* `fullName` strips both `form{N}_` prefix and `_i{M}` suffix.
*/
export function readActiveGridCellScript() {
return `(() => {
const f = document.activeElement;
if (!f) return { tag: 'none' };
if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') {
const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })();
if (inGrid) {
let headerText = '';
let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement;
if (grid) {
const fr = f.getBoundingClientRect();
const head = grid.querySelector('.gridHead');
const hl = head?.querySelector('.gridLine') || head;
if (hl) for (const h of hl.children) {
if (h.offsetWidth === 0) continue;
const hr = h.getBoundingClientRect();
if (fr.x >= hr.x && fr.x < hr.x + hr.width) {
const t = h.querySelector('.gridBoxText');
headerText = (t || h).innerText?.trim() || '';
break;
}
}
}
return {
tag: 'INPUT', id: f.id,
fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''),
headerText
};
}
}
return { tag: f.tagName || 'none' };
})()`;
}
/**
* Return center coords of the element with the given id.
* Returns `{ x, y } | null`.
*/
export function getElementCenterCoordsByIdScript(elementId) {
return `(() => {
const el = document.getElementById(${JSON.stringify(elementId)});
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
})()`;
}
@@ -0,0 +1,383 @@
// web-test dom/grid v1.3 — grid resolution + table reading + edit-time helpers
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Resolve a specific grid by semantic name (table parameter).
* Cascade: exact gridName match → gridName contains → column contains.
* Returns { gridSelector, gridId, gridName, gridIndex, columns } or { error, available }.
*/
export function resolveGridScript(formNum, tableName) {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const target = ${JSON.stringify(tableName.toLowerCase().replace(/ё/g, 'е'))};
const norm = s => (s || '').replace(/ё/gi, 'е');
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
if (!allGrids.length) return { error: 'no_grids', message: 'No grids found on form' };
const infos = allGrids.map((g, idx) => {
const gridId = g.id || '';
const gridName = gridId.replace(p, '');
const head = g.querySelector('.gridHead');
const columns = [];
if (head) {
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (text) columns.push(text);
});
}
// Visual label from group title element
const titleEl = document.getElementById(p + gridName + '#title_div')
|| document.getElementById(p + 'Группа' + gridName + '#title_div');
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\s*$/, '').replace(/ /g, ' ') || '') : '';
return { idx, gridId, gridName, label, columns, el: g };
});
// 1. Exact gridName match (case-insensitive)
let found = infos.find(i => norm(i.gridName).toLowerCase() === target);
// 2. Exact label match
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase() === target);
// 3. gridName contains target
if (!found) found = infos.find(i => norm(i.gridName).toLowerCase().includes(target));
// 4. Label contains target
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase().includes(target));
// 5. Any column contains target
if (!found) found = infos.find(i => i.columns.some(c => norm(c).toLowerCase().includes(target)));
if (found) {
return {
gridSelector: found.gridId ? '#' + CSS.escape(found.gridId) : null,
gridId: found.gridId,
gridName: found.gridName,
gridIndex: found.idx,
columns: found.columns
};
}
return {
error: 'not_found',
message: 'Table "' + ${JSON.stringify(tableName)} + '" not found',
available: infos.map(i => ({ name: i.gridName, ...(i.label ? { label: i.label } : {}), columns: i.columns }))
};
})()`;
}
/**
* Read table/grid data with pagination.
* Parses grid.innerText — \n separates rows, \t separates cells.
* First row = column headers.
* Returns { name, columns[], rows[{col:val}], total, offset, shown }.
*/
export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelector } = {}) {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const grid = ${gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
if (!grid) return { error: 'no_table', message: 'No table found on form ${formNum}' };
const name = grid.id ? grid.id.replace(p, '') : '';
// DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
if (!head || !body) {
// Fallback: innerText-based (for non-standard grids)
const gText = grid.innerText?.trim() || '';
const lines = gText.split('\\n').filter(Boolean);
return { name, columns: [], rows: [], total: lines.length, offset: 0, shown: 0,
hint: 'Grid has no gridHead/gridBody structure' };
}
// Extract column headers with X-coordinates for alignment
const columns = [];
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (!text) {
// Unnamed column — check if data cells contain checkboxes
const firstLine = body?.querySelector('.gridLine');
if (firstLine) {
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
const idx = visibleHeaders.indexOf(box);
const cells = [...firstLine.children].filter(c => c.offsetWidth > 0);
if (cells[idx]?.querySelector('.checkbox')) {
const r = box.getBoundingClientRect();
columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
}
}
return;
}
const r = box.getBoundingClientRect();
columns.push({ text, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
});
// Multi-row grid support: detect stacked/merged headers.
// Group headers by X-range. For each group, count data sub-rows from first line.
// - Stacked headers (2+ headers at same X) with multiple data rows → match by Y-order
// - Single merged header with multiple data rows → expand to numbered columns (e.g. "Субконто Дт 1")
const xGroups = new Map();
columns.forEach(c => {
const key = Math.round(c.x) + ':' + Math.round(c.right);
if (!xGroups.has(key)) xGroups.set(key, []);
xGroups.get(key).push(c);
});
for (const [, hdrs] of xGroups) hdrs.sort((a, b) => a.y - b.y);
const firstDataLine = body?.querySelector('.gridLine');
const subRowMap = new Map();
if (firstDataLine) {
[...firstDataLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const r = box.getBoundingClientRect();
const cx = r.x + r.width / 2;
for (const [key, hdrs] of xGroups) {
const h0 = hdrs[0];
if (cx >= h0.x && cx < h0.right) {
if (!subRowMap.has(key)) subRowMap.set(key, []);
subRowMap.get(key).push({ y: r.y });
break;
}
}
});
for (const [, subs] of subRowMap) subs.sort((a, b) => a.y - b.y);
}
const multiRowGroups = new Map();
for (const [key, hdrs] of xGroups) {
const subs = subRowMap.get(key);
if (!subs || subs.length <= 1) continue;
if (hdrs.length >= 2) {
multiRowGroups.set(key, hdrs);
} else if (hdrs.length === 1 && subs.length > 1) {
const base = hdrs[0];
const baseIdx = columns.indexOf(base);
columns.splice(baseIdx, 1);
const expanded = [];
for (let si = 0; si < subs.length; si++) {
const numbered = {
text: base.text + ' ' + (si + 1),
x: base.x, w: base.w, right: base.right,
y: base.y + si, h: base.h / subs.length, _subIdx: si
};
columns.splice(baseIdx + si, 0, numbered);
expanded.push(numbered);
}
multiRowGroups.set(key, expanded);
}
}
function matchColumn(cellX, cellW, cellY) {
const cx = cellX + cellW / 2;
for (const [key, hdrs] of multiRowGroups) {
const h0 = hdrs[0];
if (cx >= h0.x && cx < h0.right) {
const subs = subRowMap.get(key);
if (subs) {
const subIdx = subs.findIndex(s => Math.abs(s.y - cellY) < 5);
if (subIdx >= 0 && subIdx < hdrs.length) return hdrs[subIdx];
}
let best = hdrs[0], bestDist = Infinity;
for (const h of hdrs) {
const dist = Math.abs(cellY - h.y);
if (dist < bestDist) { bestDist = dist; best = h; }
}
return best;
}
}
return columns.find(c => cx >= c.x && cx < c.right);
}
// Extract data rows from gridBody
const allLines = body.querySelectorAll('.gridLine');
const total = allLines.length;
const rows = [];
const end = Math.min(${offset} + ${maxRows}, total);
for (let i = ${offset}; i < end; i++) {
const line = allLines[i];
if (!line) break;
const row = {};
columns.forEach(c => { row[c.text] = ''; });
[...line.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const chk = box.querySelector('.checkbox');
let val;
if (chk) {
val = chk.classList.contains('select') ? 'true' : 'false';
} else {
val = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (!val) return;
}
// Match cell to column by X+Y overlap (multi-row aware)
const r = box.getBoundingClientRect();
const col = matchColumn(r.x, r.width, r.y);
if (col) {
row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val;
}
});
// Detect row kind: group (gridListH), parent/up (gridListV), or element
const imgBox = line.querySelector('.gridBoxImg');
if (imgBox) {
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
}
// Tree mode: detect expand/collapse state and indent level
const treeBox = line.querySelector('.gridBoxTree');
if (treeBox) {
const treeIcon = imgBox?.querySelector('[tree="true"]');
if (treeIcon) {
const bg = treeIcon.style.backgroundImage || '';
row._tree = bg.includes('gx=0') ? 'expanded' : 'collapsed';
}
row._level = imgBox ? imgBox.querySelectorAll('.dIB').length - 1 : 0;
}
// Selection state: selRow = selected row in grid
if (line.classList.contains('selRow') || line.classList.contains('select')) row._selected = true;
rows.push(row);
}
const isTree = !!body.querySelector('.gridBoxTree');
const hasGroups = rows.some(r => r._kind === 'group');
const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length };
if (isTree) result.viewMode = 'tree';
if (hasGroups) result.hierarchical = true;
return result;
})()`;
}
// ─── Edit-time grid helpers (for fillTableRow / row-fill) ────────────────────
//
// All helpers below accept an optional `gridSelector`. When passed, they target
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
// the page (this matches the implicit "current grid" used by row-fill).
/** Inline JS fragment that resolves the target grid into `const grid`. */
function gridResolver(gridSelector) {
return gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
}
/**
* Find center coords of a target row for click-select (used by deleteTableRow).
* Picks the second visible gridBox container in the row (skips row-number/checkbox col).
*
* Returns `{ x, y, total } | { error: 'no_grid'|'no_grid_body'|'row_out_of_range'|'no_cell', total? }`.
*/
export function findDeleteRowCoordsScript(gridSelector, row) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return { error: 'no_grid' };
const body = grid.querySelector('.gridBody');
if (!body) return { error: 'no_grid_body' };
const rows = [...body.querySelectorAll('.gridLine')];
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
const line = rows[${row}];
// Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
// Skip first column (row number / checkbox) — pick second visible box
const box = boxes.length > 1 ? boxes[1] : boxes[0];
if (!box) return { error: 'no_cell' };
const cell = box.querySelector('.gridBoxText') || box;
const r = cell.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length };
})()`;
}
/**
* Count `.gridLine` rows in the body of the target grid.
* Returns the row count, or `0` when grid/body absent.
*/
export function countGridRowsScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
const body = grid?.querySelector('.gridBody');
return body ? body.querySelectorAll('.gridLine').length : 0;
})()`;
}
/**
* Is the target grid a tree grid? (presence of `.gridBoxTree`)
* Returns boolean.
*/
export function isTreeGridScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
return grid ? !!grid.querySelector('.gridBoxTree') : false;
})()`;
}
/**
* Return center coords of the grid's `.gridHead` element.
* Used as a click target to commit a pending cell edit (clicking the header
* defocuses the input without selecting another row).
*
* Returns `{ x, y } | null`.
*/
export function findGridHeadCenterCoordsScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const head = grid.querySelector('.gridHead');
if (!head) return null;
const r = head.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Return the index of the currently selected row in the target grid, or
* fall back to the last row when nothing is selected.
*
* Returns row index, or `-1` when no rows.
*/
export function getSelectedOrLastRowIndexScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return -1;
const body = grid.querySelector('.gridBody');
if (!body) return -1;
const lines = [...body.querySelectorAll('.gridLine')];
const sel = lines.findIndex(l => l.classList.contains('selected'));
return sel >= 0 ? sel : lines.length - 1;
})()`;
}
/**
* Scan a form's grid for a row matching `searchLower` (case- and ё-insensitive,
* NBSP-normalised). Match order: exact → startsWith → includes.
*
* When `searchLower` is empty, returns coords of the first row (fallback).
*
* Returns `{ rowCount, x, y, isGroup } | { rowCount: 0 } | null`.
*/
export function scanGridRowsScript(formNum, searchLower) {
return `(() => {
const p = 'form${formNum}_';
const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid');
if (!grid) return null;
const body = grid.querySelector('.gridBody');
if (!body) return null;
const lines = [...body.querySelectorAll('.gridLine')];
if (!lines.length) return { rowCount: 0 };
const searchLower = ${JSON.stringify(searchLower || '')};
let sel = null;
if (searchLower) {
const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е');
const rowData = lines.map(l => ({ el: l, text: norm(l.innerText) }));
sel = rowData.find(r => r.text === searchLower)?.el
|| rowData.find(r => r.text.startsWith(searchLower))?.el
|| rowData.find(r => r.text.includes(searchLower))?.el;
} else {
sel = lines[0]; // empty search → first row
}
if (!sel) return null;
const imgBox = sel.querySelector('.gridBoxImg');
const isGroup = imgBox ? !!imgBox.querySelector('.gridListH') : false;
const r = sel.getBoundingClientRect();
return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isGroup };
})()`;
}
@@ -0,0 +1,93 @@
// web-test dom/nav v1.0 — sections panel, tabs bar, function panel commands
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/** Read sections panel (left sidebar). */
export function readSectionsScript() {
return `(() => {
const sections = [];
document.querySelectorAll('[id^="themesCell_theme_"]').forEach(el => {
const entry = { name: el.innerText?.trim() || '' };
if (el.classList.contains('select')) entry.active = true;
sections.push(entry);
});
return sections;
})()`;
}
/** Read open tabs bar. */
export function readTabsScript() {
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const tabs = [];
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
const text = norm(el.innerText);
if (!text) return;
const entry = { name: text };
if (el.classList.contains('select')) entry.active = true;
tabs.push(entry);
});
return tabs;
})()`;
}
/** Switch to a tab by name (fuzzy match). Returns matched name or { error, available }. */
export function switchTabScript(name) {
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))};
const tabs = [...document.querySelectorAll('[id^="openedCell_cmd_"]')].filter(el => el.offsetWidth > 0 && norm(el.innerText));
let best = tabs.find(el => norm(el.innerText).toLowerCase() === target);
if (!best) best = tabs.find(el => norm(el.innerText).toLowerCase().includes(target));
if (best) { best.click(); return norm(best.innerText); }
return { error: 'not_found', available: tabs.map(el => norm(el.innerText)) };
})()`;
}
/** Read commands in the function panel (current section). */
export function readCommandsScript() {
return `(() => {
const groups = [];
const container = document.querySelector('#funcPanel_container table tr');
if (!container) return groups;
for (const td of container.children) {
const commands = [];
td.querySelectorAll('[id^="cmd_"][id$="_txt"]').forEach(el => {
if (el.offsetWidth === 0) return;
commands.push(el.innerText?.trim() || '');
});
if (commands.length > 0) groups.push(commands);
}
return groups;
})()`;
}
/**
* Navigate to a section by name (fuzzy match).
* Returns the matched section name, or { error, available }.
*/
export function navigateSectionScript(name) {
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ').replace(/[\\r\\n]+/g, ' ').replace(/ +/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е').replace(/[\r\n]+/g, ' ').replace(/ +/g, ' '))};
const els = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target);
if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target));
if (bestEl) { bestEl.click(); return norm(bestEl.innerText); }
return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) };
})()`;
}
/**
* Open a command from function panel by name (fuzzy match).
*/
export function openCommandScript(name) {
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))};
const els = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(el => el.offsetWidth > 0);
let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target);
if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target));
if (bestEl) { bestEl.click(); return norm(bestEl.innerText); }
return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) };
})()`;
}
@@ -0,0 +1,149 @@
// web-test dom/submenu v1.0 — popup/submenu reading and clicking
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Read open popup/submenu items.
* Looks for absolutely positioned visible popup containers with a.press items inside.
* Returns [{ id, name }] or { error }.
*/
export function readSubmenuScript() {
return `(() => {
const items = [];
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
// 1. DLB dropdown (#editDropDown with .eddText items)
const edd = document.getElementById('editDropDown');
if (edd && edd.offsetWidth > 0 && edd.offsetHeight > 0) {
edd.querySelectorAll('.eddText').forEach(el => {
if (el.offsetWidth === 0) return;
const text = norm(el.innerText);
if (!text) return;
const r = el.getBoundingClientRect();
items.push({ id: '', name: text, kind: 'dropdown',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
});
// Detect "Показать все" link in EDD footer
// Structure: div.eddBottom > div > span.hyperlink "Показать все"
let showAllEl = edd.querySelector('.eddBottom .hyperlink');
if (!showAllEl || showAllEl.offsetWidth === 0) {
// Fallback: scan all visible elements for text match
const candidates = [...edd.querySelectorAll('a.press, a, span, div')]
.filter(el => el.offsetWidth > 0 && el.children.length === 0);
showAllEl = candidates.find(el => {
const t = norm(el.innerText).toLowerCase();
return t === 'показать все' || t === 'show all';
});
}
if (showAllEl) {
const r = showAllEl.getBoundingClientRect();
items.push({ id: showAllEl.id || '', name: norm(showAllEl.innerText), kind: 'showAll',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
}
if (items.length > 0) return items;
}
// 2. Cloud submenu (allActions / command panel menus — div.cloud with .submenuText items)
// Read ALL visible high-z clouds (main menu + nested submenus)
const clouds = [...document.querySelectorAll('.cloud')].filter(c => c.offsetWidth > 0 && c.offsetHeight > 0);
const seen = new Set();
clouds.forEach(c => {
const z = parseInt(getComputedStyle(c).zIndex) || 0;
if (z <= 1000) return;
c.querySelectorAll('.submenuText').forEach(el => {
if (el.offsetWidth === 0) return;
const text = norm(el.innerText);
if (!text || seen.has(text)) return;
seen.add(text);
const block = el.closest('.submenuBlock');
if (block && block.classList.contains('submenuBlockDisabled')) return;
const hasSub = block && /_sub$/.test(block.id);
const r = el.getBoundingClientRect();
items.push({ id: block?.id || '', name: text, kind: hasSub ? 'submenuArrow' : 'submenu',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
});
});
if (items.length > 0) return items;
// 3. Submenu popups — find the topmost positioned container with non-form a.press items
const popups = [...document.querySelectorAll('div')].filter(c => {
const style = getComputedStyle(c);
return (style.position === 'absolute' || style.position === 'fixed')
&& c.offsetWidth > 0 && c.offsetHeight > 0;
}).sort((a, b) => {
const za = parseInt(getComputedStyle(a).zIndex) || 0;
const zb = parseInt(getComputedStyle(b).zIndex) || 0;
return zb - za;
});
for (const container of popups) {
// Only direct a.press children or those not nested in another positioned div
const menuItems = [...container.querySelectorAll('a.press')].filter(el => {
if (el.offsetWidth === 0) return false;
if (el.id && /^form\\d+_/.test(el.id)) return false;
// Skip if this a.press is inside a deeper positioned container
let parent = el.parentElement;
while (parent && parent !== container) {
const ps = getComputedStyle(parent).position;
if (ps === 'absolute' || ps === 'fixed') return false;
parent = parent.parentElement;
}
return true;
});
if (menuItems.length < 2) continue; // Not a real menu
const seen = new Set();
menuItems.forEach(el => {
const text = norm(el.innerText);
if (!text) return;
if (seen.has(text)) return;
seen.add(text);
const r = el.getBoundingClientRect();
items.push({ id: el.id || '', name: text, kind: 'submenu',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
});
if (items.length > 0) break; // Found the popup menu
}
if (items.length === 0) return { error: 'no_popup', message: 'No open popup/submenu found' };
return items;
})()`;
}
/**
* Click a popup/dropdown item by text match (evaluate-based for items without IDs).
* Returns true if clicked, false if not found.
*/
export function clickPopupItemScript(text) {
return `(() => {
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
// 1. DLB dropdown (#editDropDown .eddText items)
const edd = document.getElementById('editDropDown');
if (edd && edd.offsetWidth > 0) {
for (const el of edd.querySelectorAll('.eddText')) {
if (el.offsetWidth === 0) continue;
const t = el.innerText?.trim() || '';
if (t.toLowerCase() === target || t.toLowerCase().includes(target)) {
el.click();
return t;
}
}
}
// 2. Submenu popups (a.press in absolutely positioned containers)
const containers = [...document.querySelectorAll('div')].filter(c => {
const style = getComputedStyle(c);
return (style.position === 'absolute' || style.position === 'fixed')
&& c.offsetWidth > 0 && c.offsetHeight > 0;
});
for (const container of containers) {
const items = [...container.querySelectorAll('a.press')]
.filter(el => el.offsetWidth > 0);
for (const el of items) {
const t = el.innerText?.trim() || '';
if (t.toLowerCase() === target || t.toLowerCase().includes(target)) {
el.click();
return t;
}
}
}
return null;
})()`;
}
@@ -0,0 +1,105 @@
// web-test core/click v1.20 — clickElement dispatcher: routes to spreadsheet / popup / grid-row / form-element handlers by target kind.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page, ensureConnected, highlightMode } from './state.mjs';
import {
detectFormScript, findClickTargetScript, resolveGridScript,
readSubmenuScript,
} from '../../dom.mjs';
import { dismissPendingErrors, checkForErrors } from './errors.mjs';
import { waitForStable } from './wait.mjs';
import { highlight, unhighlight } from '../recording/highlight.mjs';
import { modifierClick, returnFormState } from './helpers.mjs';
import {
clickGridGroupTarget, clickGridTreeNodeTarget, clickGridRowTarget,
} from '../table/click-row.mjs';
import {
clickConfirmationButton, tryClickPopupItem,
} from '../forms/click-popup.mjs';
import { clickFormTarget } from '../forms/click-form.mjs';
import {
clickSpreadsheetCell, findSpreadsheetCellByText,
} from '../spreadsheet/spreadsheet.mjs';
/** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists).
* First argument can also be an object { row, column } to click a SpreadsheetDocument cell. */
export async function clickElement(text, { dblclick, table, toggle, expand, modifier, timeout } = {}) {
ensureConnected();
// Dispatch to spreadsheet cell handler when first arg is { row, column }
if (typeof text === 'object' && text !== null && text.column != null) {
await dismissPendingErrors();
return clickSpreadsheetCell(text, { dblclick, modifier });
}
await dismissPendingErrors();
if (highlightMode) {
try { await highlight(text, { table }); await page.waitForTimeout(500); await unhighlight(); } catch {}
}
try {
// 1. Intercept open confirmation dialog (Да/Нет/Отмена) — match button by text.
const pending = await checkForErrors();
if (pending?.confirmation) {
return await clickConfirmationButton(text);
}
// 2. Intercept open popup (from previous submenu/split-button click).
// Returns null if popup is open but `text` doesn't match — fall through.
const popupItems = await page.evaluate(readSubmenuScript());
if (Array.isArray(popupItems) && popupItems.length > 0) {
const popupResult = await tryClickPopupItem(text, popupItems);
if (popupResult) return popupResult;
}
// 3. Find a target on the current form.
let formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error(`clickElement: no form found`);
let gridSelector;
if (table) {
const resolved = await page.evaluate(resolveGridScript(formNum, table));
if (resolved.error) throw new Error(`clickElement: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
gridSelector = resolved.gridSelector;
}
let target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
// Retry: if not found, a modal form may still be loading (e.g. after F4).
if (target?.error) {
for (let retry = 0; retry < 4; retry++) {
await page.waitForTimeout(500);
const newForm = await page.evaluate(detectFormScript());
if (newForm !== null && newForm !== formNum) {
formNum = newForm;
target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
if (!target?.error) break;
}
}
}
// Spreadsheet fallback: search iframes for text match before giving up.
if (target?.error) {
const ssCell = await findSpreadsheetCellByText(formNum, text);
if (ssCell) {
const cx = ssCell.box.x + ssCell.box.width / 2;
const cy = ssCell.box.y + ssCell.box.height / 2;
await modifierClick(cx, cy, modifier, { dbl: !!dblclick });
await waitForStable();
return returnFormState({
clicked: { kind: 'spreadsheetCell', name: ssCell.text, ...(dblclick ? { dblclick: true } : {}) },
});
}
throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`);
}
// 4. Dispatch to the right handler by target kind.
const ctx = { formNum, modifier, dblclick, toggle, expand, timeout, table, gridSelector };
if (target.kind === 'gridGroup' || target.kind === 'gridParent') return await clickGridGroupTarget(target, ctx);
if (target.kind === 'gridTreeNode') return await clickGridTreeNodeTarget(target, ctx);
if (target.kind === 'gridRow') return await clickGridRowTarget(target, ctx);
return await clickFormTarget(target, ctx);
} finally {
if (highlightMode) try { await unhighlight(); } catch {}
}
}
@@ -0,0 +1,97 @@
// web-test engine/core/clipboard v1.17 — OS-clipboard preservation around trusted paste.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// pasteText() — the only path 1C respects for autocomplete and Cyrillic input.
// saveClipboard/restoreClipboard preserve full clipboard contents (all MIME
// types) around the writeText+Ctrl+V pair so a user's concurrent Ctrl+C isn't
// clobbered. Blobs are stashed on `window` to avoid CDP serialization.
import {
page, preserveClipboard, clipboardWarnLogged, setClipboardWarnLogged,
} from './state.mjs';
export async function saveClipboard() {
if (!page) return;
try {
await page.evaluate(async () => {
try {
const items = await navigator.clipboard.read();
const saved = [];
for (const item of items) {
const types = {};
for (const t of item.types) types[t] = await item.getType(t);
saved.push(types);
}
window.__webTestSavedClipboard = saved;
delete window.__webTestClipboardError;
} catch (e) {
window.__webTestSavedClipboard = null;
window.__webTestClipboardError = e?.name || String(e);
}
});
} catch {
// page.evaluate itself failed (closed page, navigation in flight) — skip.
}
}
export async function restoreClipboard() {
if (!page) return;
let err = null;
try {
err = await page.evaluate(async () => {
const saved = window.__webTestSavedClipboard;
const captured = window.__webTestClipboardError || null;
delete window.__webTestSavedClipboard;
delete window.__webTestClipboardError;
try {
if (!saved || saved.length === 0) {
// Save failed (e.g. CF_HDROP from Explorer not readable via Clipboard API)
// or buffer was empty. Either way, the test's writeText already destroyed
// any prior native formats in the OS clipboard, so explicitly clear here
// to avoid leaking the test value into the user's clipboard.
await navigator.clipboard.writeText('');
return captured;
}
const items = saved.map(types => new ClipboardItem(types));
await navigator.clipboard.write(items);
return null;
} catch (e) {
return e?.name || String(e);
}
});
} catch {
return;
}
if (err && !clipboardWarnLogged) {
setClipboardWarnLogged(true);
console.error(`[web-test] clipboard preserve skipped: ${err} (logged once per session)`);
}
}
/**
* Paste `text` via OS clipboard (the only trusted-paste path that 1C respects
* for autocomplete and Cyrillic). Wraps the writeText+confirm-key pair in a
* narrow save/restore so a user's clipboard survives the test run — the window
* between save and restore is microseconds.
*
* - `confirm` — key (string) or sequence (array) to press after writeText.
* Defaults to 'Control+V'. Use ['Control+a', 'Control+v'] for select-all-then-paste,
* or 'Shift+F11' for the goto-link dialog.
* - `postDelay` — ms to wait between confirm-press and restore, for dialogs
* that read clipboard asynchronously (e.g. Shift+F11). Default 0.
*/
export async function pasteText(text, { confirm = 'Control+V', postDelay = 0 } = {}) {
if (!page) return;
if (preserveClipboard) await saveClipboard();
try {
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
if (Array.isArray(confirm)) {
for (const key of confirm) await page.keyboard.press(key);
} else if (confirm) {
await page.keyboard.press(confirm);
}
if (postDelay) await page.waitForTimeout(postDelay);
} finally {
if (preserveClipboard) await restoreClipboard();
}
}
@@ -0,0 +1,310 @@
// web-test core/errors v1.18 — Error/modal/platform-dialog handling: dismiss, detect, fetch stack from 1C UI.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page } from './state.mjs';
import { checkErrorsScript } from '../../dom.mjs';
import {
getOpenReportCoordsScript, isErrorDetailLinkVisibleScript,
readLargestVisibleTextareaScript, clickTopCloudOkButtonScript,
clickReportCloseButtonScript,
} from '../../dom/errors-stack.mjs';
import { waitForStable } from './wait.mjs';
/**
* Close startup modals and guide tabs.
* Strategy: Escape → click default buttons → close extra tabs → repeat.
*/
export async function closeModals() {
for (let attempt = 0; attempt < 5; attempt++) {
// 1. Press Escape to dismiss any popup/modal
await page.keyboard.press('Escape');
await page.waitForTimeout(1000);
// 2. Try clicking default "Закрыть"/"OK" buttons
const clicked = await page.evaluate(`(() => {
const btns = [...document.querySelectorAll('a.press.pressDefault')].filter(el => el.offsetWidth > 0);
for (const btn of btns) {
const text = (btn.innerText?.trim() || '').toLowerCase();
if (['закрыть', 'ok', 'ок', 'нет', 'отмена'].includes(text)) {
btn.click();
return text;
}
}
return null;
})()`);
if (clicked) { await page.waitForTimeout(1000); continue; }
// 3. Close extra tabs (Путеводитель etc.) via openedClose button
const tabClosed = await page.evaluate(`(() => {
const btn = document.querySelector('.openedClose');
if (btn && btn.offsetWidth > 0) { btn.click(); return true; }
return false;
})()`);
if (tabClosed) { await page.waitForTimeout(1000); continue; }
// Nothing to close — done
break;
}
}
/**
* Check for validation errors / diagnostics after an action.
* Detects: inline balloon tooltip, messages panel, modal error dialog.
* Returns { balloon, messages[], modal } or null.
*/
export async function checkForErrors() {
return await page.evaluate(checkErrorsScript());
}
/**
* Dismiss pending error modal if present (single OK button dialog).
* Called at the start of action functions so that a leftover error modal
* from a previous operation doesn't block the next action.
* Does NOT dismiss confirmations (Да/Нет — require user decision).
* Returns the dismissed error object or null.
*/
export async function dismissPendingErrors() {
// Close leftover platform dialogs first (About, Support Info, Error Report)
// These block all interaction via modalSurface and are invisible to 1C form detection
try {
const pd = await detectPlatformDialogs();
if (pd.length) await closePlatformDialogs();
} catch { /* OK */ }
const err = await checkForErrors();
if (!err?.modal) return null;
try {
// Target pressDefault within the modal's form container specifically
const formNum = err.modal.formNum;
const sel = formNum != null
? `#form${formNum}_container a.press.pressDefault`
: 'a.press.pressDefault';
const btn = await page.$(sel);
if (btn) { await btn.click({ force: true }); await page.waitForTimeout(500); }
} catch { /* OK */ }
await waitForStable();
return err;
}
/**
* Detect open platform-level dialogs (About, Support Info, Error Report).
* Returns array of { type, title? } for each detected dialog, or empty array.
*/
export async function detectPlatformDialogs() {
return await page.evaluate(() => {
const result = [];
// "О программе" dialog
const about = document.getElementById('aboutContainer');
if (about && about.offsetWidth > 0) result.push({ type: 'about', title: 'О программе' });
// "Информация для технической поддержки" (inside a ps*win with errJournalInput)
const errJ = document.getElementById('errJournalInput');
if (errJ && errJ.offsetWidth > 0) result.push({ type: 'supportInfo', title: 'Информация для технической поддержки' });
// "Отчет об ошибке" / "Подробный текст ошибки" — ps*win cloud windows without aboutContainer
if (!result.length) {
document.querySelectorAll('[id^="ps"][id$="win"]').forEach(w => {
if (w.offsetWidth === 0 || w.offsetHeight === 0) return;
// Skip the main app window (ps*win that contains the 1C forms)
if (w.querySelector('[id^="form"][id$="_container"]')) return;
// Check title text
const titleEl = w.querySelector('[id$="headerTopLine_cmd_Title"]');
const title = titleEl?.textContent?.trim() || '';
if (title) result.push({ type: 'platformWindow', title });
});
}
return result;
});
}
/**
* Close any platform-level dialogs that may be left open (about, support info, error report).
* These are NOT 1C forms — they are platform UI overlays invisible to getFormState().
* Each close is wrapped in try/catch to avoid cascading failures.
*/
export async function closePlatformDialogs() {
await page.evaluate(() => {
// "Подробный текст ошибки" OK button (inside error report detail view)
// It's a cloud window with its own OK button — look for visible pressDefault in small ps*win
const psWins = document.querySelectorAll('[id^="ps"][id$="win"]');
for (const w of psWins) {
if (w.offsetWidth === 0) continue;
// Check if this is a small dialog (error detail, about, support info)
const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
if (closeBtn && closeBtn.offsetWidth > 0) {
try { closeBtn.click(); } catch {}
}
}
// "Информация для технической поддержки" — extOkBtn
const extOk = document.getElementById('extOkBtn');
if (extOk && extOk.offsetWidth > 0) try { extOk.click(); } catch {}
// "О программе" — aboutOkButton
const aboutOk = document.getElementById('aboutOkButton');
if (aboutOk && aboutOk.offsetWidth > 0) try { aboutOk.click(); } catch {}
});
await page.waitForTimeout(300);
}
/**
* Parse raw error stack text into structured entries.
* Input: raw text from errJournalInput (first block) or "Подробный текст ошибки" textarea.
* Returns { raw, timestamp?, entries: [{location, code}] }
*/
function parseErrorStack(raw) {
if (!raw) return null;
const result = { raw, entries: [] };
// Extract timestamp if present (format: DD.MM.YYYY HH:MM:SS)
const tsMatch = raw.match(/^(\d{2}\.\d{2}\.\d{4}\s+\d{1,2}:\d{2}:\d{2})/m);
if (tsMatch) result.timestamp = tsMatch[1];
// Extract {Module.Path(lineNum)}: code entries
const entryRe = /\{([^}]+)\}:\s*(.+)/g;
let m;
while ((m = entryRe.exec(raw)) !== null) {
result.entries.push({ location: m[1].trim(), code: m[2].trim() });
}
return result.entries.length > 0 ? result : null;
}
/**
* Fetch error call stack from the 1C platform UI.
* Uses two strategies:
* Path 1 (hasReport=true): Click OpenReport link → "подробный текст ошибки" → read textarea
* Path 2 (fallback): Hamburger → "О программе" → "Информация для техподдержки" → errJournalInput
*
* Always closes the error modal and any platform dialogs it opened.
* Returns parsed stack object or null on failure.
*
* @param {number} formNum - form number of the error modal (e.g. 6 for form6_)
* @param {boolean} hasReport - true if OpenReport link is available
*/
export async function fetchErrorStack(formNum, hasReport) {
try {
// Platform exception modals are initially unstable — they redraw within ~1s.
// The initial state may lack the OpenReport link. Re-check after a short delay.
if (!hasReport) {
await page.waitForTimeout(1500);
hasReport = await page.evaluate((fn) => {
const el = document.getElementById('form' + fn + '_OpenReport#text');
return !!(el && el.offsetWidth > 2 && el.textContent.trim());
}, formNum);
}
if (hasReport) return await fetchStackViaReport(formNum);
return await fetchStackViaHamburger(formNum);
} catch {
return null;
} finally {
// Ensure all platform dialogs are closed
try { await closePlatformDialogs(); } catch {}
// Ensure the error modal itself is closed
try {
const sel = formNum != null
? `#form${formNum}_container a.press.pressDefault`
: 'a.press.pressDefault';
const btn = await page.$(sel);
if (btn) await btn.click({ force: true });
await page.waitForTimeout(300);
} catch {}
}
}
/**
* Path 1: Fetch stack via OpenReport link (for platform exceptions).
* The error modal must still be open with a visible "Сформировать отчет об ошибке" link.
*/
async function fetchStackViaReport(formNum) {
// 1. Get coordinates of the OpenReport link and click via mouse (modalSurface blocks JS clicks)
const coords = await page.evaluate(getOpenReportCoordsScript(formNum));
if (!coords) return null;
await page.mouse.click(coords.x, coords.y);
// 2. Wait for "Отчет об ошибке" dialog — look for "подробный текст ошибки" link
let found = false;
for (let i = 0; i < 20; i++) {
await page.waitForTimeout(500);
found = await page.evaluate(isErrorDetailLinkVisibleScript());
if (found) break;
}
if (!found) return null;
// 3. Click "подробный текст ошибки"
await page.getByText('подробный текст ошибки').click();
await page.waitForTimeout(2000);
// 4. Read the textarea with detailed error text (find the largest visible textarea)
const raw = await page.evaluate(readLargestVisibleTextareaScript());
// 5. Close "Подробный текст ошибки" dialog (click its OK button)
try {
await page.evaluate(clickTopCloudOkButtonScript());
await page.waitForTimeout(300);
} catch {}
// 6. Close "Отчет об ошибке" dialog (click its × close button)
try {
await page.evaluate(clickReportCloseButtonScript());
await page.waitForTimeout(300);
} catch {}
return parseErrorStack(raw);
}
/**
* Path 2: Fetch stack via hamburger menu → "О программе" → "Информация для техподдержки".
* Works for all error types including simple ВызватьИсключение.
* The error modal is closed first to allow access to the hamburger menu.
*/
async function fetchStackViaHamburger(formNum) {
// 1. Close the error modal first
try {
const sel = formNum != null
? `#form${formNum}_container a.press.pressDefault`
: 'a.press.pressDefault';
const btn = await page.$(sel);
if (btn) await btn.click({ force: true });
await page.waitForTimeout(500);
} catch {}
// 2. Click hamburger menu
await page.click('#captionbarMore', { timeout: 5000 });
await page.waitForTimeout(1000);
// 3. Click "О программе..."
await page.getByText('О программе...', { exact: true }).click({ timeout: 5000 });
await page.waitForTimeout(2000);
// 4. Click "Информация для технической поддержки"
await page.click('#aboutHyperLink', { timeout: 5000 });
// 5. Wait for errJournalInput to appear and be filled
let raw = null;
for (let i = 0; i < 20; i++) {
await page.waitForTimeout(500);
raw = await page.evaluate(() => {
const el = document.getElementById('errJournalInput');
return (el && el.offsetWidth > 0 && el.value.length > 50) ? el.value : null;
});
if (raw) break;
}
if (!raw) return null;
// 6. Parse first error block (most recent — before first separator)
const separator = / - - - - /;
const errSection = raw.indexOf('\n\n') !== -1 ? raw.substring(raw.indexOf('\n\n')) : raw;
// Find the "Ошибки:" section
const errIdx = raw.indexOf('Ошибки:');
let errorText = errIdx !== -1 ? raw.substring(errIdx + 'Ошибки:'.length).trim() : raw;
// Take first block (before first separator line)
const lines = errorText.split('\n');
const firstBlockLines = [];
let inBlock = false;
for (const line of lines) {
if (separator.test(line)) {
if (inBlock) break; // end of first block
inBlock = true;
continue;
}
if (inBlock) firstBlockLines.push(line);
}
const firstBlock = firstBlockLines.join('\n').trim();
// 7. Close support info and about dialogs (done in finally via closePlatformDialogs)
return parseErrorStack(firstBlock || errorText);
}
@@ -0,0 +1,177 @@
// web-test core/helpers v1.20 — private, cross-cutting helpers used by the
// public action functions (clickElement/fillFields/selectValue/etc).
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page } from './state.mjs';
import { dismissPendingErrors, checkForErrors } from './errors.mjs';
import { getFormState } from '../forms/state.mjs';
import {
detectNewFormScript,
isInputFocusedScript,
isInputFocusedInGridScript,
findOpenPopupScript,
readEddScript,
isEddVisibleScript,
clickEddItemViaDispatchScript,
clickShowAllInEddScript,
} from '../../dom.mjs';
/**
* page.click with the standard "intercepts pointer events" retry ladder:
* normal → force → Escape (+ optional dismissPendingErrors) → normal.
* Mirrors the three hand-written copies in fillReferenceField, clickElement
* and the DLB branch of selectValue.
*
* @param {string} selector
* @param {object} [opts]
* @param {number} [opts.timeout] — passed through to page.click
* @param {boolean} [opts.dismissErrors=false] — call dismissPendingErrors()
* before pressing Escape on the second retry (used in fillReferenceField).
*/
export async function safeClick(selector, { timeout, dismissErrors = false } = {}) {
const baseOpts = timeout != null ? { timeout } : {};
try {
await page.click(selector, baseOpts);
} catch (e) {
if (!e.message.includes('intercepts pointer events')) throw e;
try {
await page.click(selector, { ...baseOpts, force: true });
} catch (e2) {
if (!e2.message.includes('intercepts pointer events')) throw e2;
if (dismissErrors) await dismissPendingErrors();
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
await page.click(selector, baseOpts);
}
}
}
/**
* Find a form field's input element id by name. Tries `form{N}_{name}` first,
* then `form{N}_{name}_i0` (reference fields use the _i0 suffix). Returns the
* element id or null. Used in selectValue's clear/composite-type/F4 fallback
* branches.
*
* @param {number} formNum
* @param {string} fieldName
* @returns {Promise<string|null>}
*/
export async function findFieldInputId(formNum, fieldName) {
return await page.evaluate(`(() => {
const p = 'form${formNum}_';
const name = ${JSON.stringify(fieldName)};
const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]');
return el ? el.id : null;
})()`);
}
/**
* Detect a new form opened above the given `prevFormNum`. Two modes:
* `{ strict: true }` — only counts visible interactive elements
* (`input.editInput[id], a.press[id]`); used by fillReferenceField.
* default (broad) — any element with `id^=form{N}_` that's visible
* in either dimension; also finds type-dialogs whose a.press buttons
* have empty IDs. Used by selectValue / fillTableRow.
*
* @param {number} prevFormNum
* @param {object} [opts]
* @param {boolean} [opts.strict=false]
* @returns {Promise<number|null>} new form number or null
*/
export async function detectNewForm(prevFormNum, { strict = false } = {}) {
return page.evaluate(detectNewFormScript(prevFormNum, { strict }));
}
/**
* Thin wrapper: is the currently focused element an INPUT (or TEXTAREA)?
*
* @param {object} [opts]
* @param {boolean} [opts.allowTextarea=false]
*/
export async function isInputFocused({ allowTextarea = false } = {}) {
return page.evaluate(isInputFocusedScript({ allowTextarea }));
}
/**
* Thin wrapper: is the currently focused INPUT/TEXTAREA inside a `.grid`?
* Used to verify grid edit-mode.
*/
export async function isInputFocusedInGrid() {
return page.evaluate(isInputFocusedInGridScript());
}
/**
* Thin wrapper: is calculator (`.calculate`) or calendar (`.frameCalendar`)
* popup visible? Returns `'calculator' | 'calendar' | null`.
*/
export async function findOpenPopup() {
return page.evaluate(findOpenPopupScript());
}
/**
* Read the `#editDropDown` autocomplete popup. Returns whether it's visible
* and, when visible, an array of `.eddText` items with display name and
* center coordinates (suitable for page.mouse.click).
*
* @returns {Promise<{visible: boolean, items?: Array<{name:string, x:number, y:number}>}>}
*/
export async function readEdd() {
return page.evaluate(readEddScript());
}
/**
* Thin wrapper: is the EDD popup currently visible?
* Lighter than `readEdd` when only presence matters.
*/
export async function isEddVisible() {
return page.evaluate(isEddVisibleScript());
}
/**
* Click an EDD item by name via dispatchEvent (bypasses div.surface overlays).
* Returns the clicked item's innerText, or `null` if no match.
*/
export async function clickEddItemViaDispatch(itemName) {
return page.evaluate(clickEddItemViaDispatchScript(itemName));
}
/**
* Click the "Показать все" / "Show all" link in the EDD footer.
* Returns boolean.
*/
export async function clickShowAllInEdd() {
return page.evaluate(clickShowAllInEddScript());
}
/**
* Standard "tail" of action functions: fetch current form state, attach
* caller-specified extras (e.g. `{ clicked: {...} }`) and the result of
* `checkForErrors()` if any. Returns the flat state object.
*
* Unifies ~15 hand-written copies in clickElement, selectValue, closeForm,
* navigation functions, etc. Also closes R1/R2/R3 from the refactor plan —
* any caller using this helper gets `state.errors` for free.
*
* @param {object} [extras] — merged into the state object via Object.assign.
* @returns {Promise<object>} form state (flat) with optional `errors`.
*/
export async function returnFormState(extras = {}) {
const state = await getFormState();
Object.assign(state, extras);
const err = await checkForErrors();
if (err) state.errors = err;
return state;
}
/**
* Mouse click at (x, y) with an optional modifier key held down for the duration.
* Supports `'ctrl'` / `'shift'` (used by clickElement for multi-select).
* Pass `{ dbl: true }` for double-click.
*/
export async function modifierClick(x, y, modifier, { dbl = false } = {}) {
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
if (modKey) await page.keyboard.down(modKey);
if (dbl) await page.mouse.dblclick(x, y);
else await page.mouse.click(x, y);
if (modKey) await page.keyboard.up(modKey);
}
@@ -0,0 +1,404 @@
// web-test core/session v1.17 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { chromium } from 'playwright';
import { statSync, mkdirSync, readdirSync, rmSync } from 'fs';
import { join as pathJoin } from 'path';
import { tmpdir } from 'os';
import {
browser, page, sessionPrefix, seanceId, recorder, highlightMode,
contexts, activeContextName, activeMode, persistentUserDataDir,
setBrowser, setPage, setSessionPrefix, setSeanceId, setHighlightMode,
setActiveContextName, setActiveMode, setPersistentUserDataDir,
isConnected, LOAD_TIMEOUT, INIT_TIMEOUT, EXT_ID,
} from './state.mjs';
import { closeModals } from './errors.mjs';
import { stopRecording } from '../recording/capture.mjs';
import { getPageState } from '../nav/navigation.mjs';
/**
* Find the 1C browser extension in Chrome/Edge user profiles.
* Returns the path to the latest version, or null if not found.
* Can be overridden via extensionPath in .v8-project.json.
*/
function findExtension(overridePath) {
if (overridePath) {
try { if (statSync(overridePath).isDirectory()) return overridePath; } catch {}
return null;
}
const localAppData = process.env.LOCALAPPDATA;
if (!localAppData) return null;
const browsers = [
pathJoin(localAppData, 'Google', 'Chrome', 'User Data'),
pathJoin(localAppData, 'Microsoft', 'Edge', 'User Data'),
];
for (const userData of browsers) {
try { if (!statSync(userData).isDirectory()) continue; } catch { continue; }
let profiles;
try { profiles = readdirSync(userData).filter(d => d === 'Default' || d.startsWith('Profile ')); } catch { continue; }
for (const profile of profiles) {
const extDir = pathJoin(userData, profile, 'Extensions', EXT_ID);
try { if (!statSync(extDir).isDirectory()) continue; } catch { continue; }
let versions;
try { versions = readdirSync(extDir).filter(d => /^\d/.test(d)).sort(); } catch { continue; }
if (versions.length > 0) {
const best = pathJoin(extDir, versions[versions.length - 1]);
try { if (statSync(pathJoin(best, 'manifest.json')).isFile()) return best; } catch {}
}
}
}
return null;
}
/* isConnected moved to core/state.mjs */
/**
* Open browser and navigate to 1C web client URL.
* Waits for initialization (themesCell_theme_0 selector) and attempts to close startup modals.
*/
export async function connect(url, { extensionPath } = {}) {
if (isConnected()) {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
} else {
const extPath = findExtension(extensionPath);
if (extPath) {
// Launch with 1C browser extension via persistent context
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-ext-' + Date.now()));
mkdirSync(persistentUserDataDir, { recursive: true });
const context = await chromium.launchPersistentContext(persistentUserDataDir, {
headless: false,
args: [
'--start-maximized',
'--disable-extensions-except=' + extPath,
'--load-extension=' + extPath,
],
viewport: null,
permissions: ['clipboard-read', 'clipboard-write'],
});
setBrowser(context); // persistent context IS the browser
setPage(context.pages()[0] || await context.newPage());
} else {
// Fallback: launch without extension
setBrowser(await chromium.launch({ headless: false, args: ['--start-maximized'] }));
const context = await browser.newContext({
viewport: null,
permissions: ['clipboard-read', 'clipboard-write'],
});
setPage(await context.newPage());
}
// Auto-accept native browser dialogs (confirm/alert from 1C scripts like vis.js)
page.on('dialog', dialog => dialog.accept().catch(() => {}));
// Capture seanceId from network requests for graceful logout
setSessionPrefix(null);
setSeanceId(null);
page.on('request', req => {
if (seanceId) return;
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
if (m) { setSessionPrefix(m[1]); setSeanceId(m[2]); }
});
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
}
// Wait for 1C to initialize — detect by section panel appearance
try {
await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT });
} catch {
// Fallback: wait fixed time if selector doesn't appear (e.g. login page)
await page.waitForTimeout(5000);
}
// Try to close startup modals (Путеводитель etc.)
await closeModals();
return await getPageState();
}
/**
* Best-effort POST /e1cib/logout on a slot to release the 1C session license.
* Silent — if page is closed or session info missing, just returns.
* @param {object} slot { page, sessionPrefix, seanceId } from contexts Map
* @param {number} [waitMs=500] pause after logout fetch (gives 1C time to process)
*/
async function logoutSlot(slot, waitMs = 500) {
if (!slot?.page || slot.page.isClosed() || !slot.seanceId || !slot.sessionPrefix) return;
try {
const logoutUrl = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
await slot.page.evaluate(async (url) => {
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
}, logoutUrl);
await slot.page.waitForTimeout(waitMs);
} catch {}
}
/**
* Gracefully terminate the 1C session and close the browser.
* Sends POST /e1cib/logout to release the license before closing.
*/
export async function disconnect() {
// Multi-context path: stop recording + logout each slot before closing browser
if (contexts.size > 0) {
saveActiveSlot();
// Recorder is global — one stop covers all contexts
if (recorder) {
try { await stopRecording(); } catch {}
}
for (const [, slot] of contexts.entries()) {
await logoutSlot(slot);
}
contexts.clear();
setActiveContextName(null);
setActiveMode(null);
}
// Single-session path (connect): auto-stop recording if active
if (recorder) {
try { await stopRecording(); } catch {}
}
if (browser) {
// Graceful logout — release the 1C license (single-session connect path)
await logoutSlot({ page, sessionPrefix, seanceId }, 1000);
await browser.close().catch(() => {});
setBrowser(null);
setPage(null);
setSessionPrefix(null);
setSeanceId(null);
// Clean up persistent user data dir
if (persistentUserDataDir) {
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
setPersistentUserDataDir(null);
}
}
}
/**
* Attach to a running browser server via CDP WebSocket.
* Sets module state so all functions (getFormState, clickElement, etc.) work.
*/
export async function attach(wsEndpoint, session = {}) {
if (isConnected()) return;
setBrowser(await chromium.connect(wsEndpoint));
const ctx = browser.contexts()[0];
setPage(ctx?.pages()[0]);
if (!page) throw new Error('No page found in browser');
setSessionPrefix(session.sessionPrefix || null);
setSeanceId(session.seanceId || null);
}
/**
* Detach from browser without closing it.
* Returns session state for persistence.
*/
export function detach() {
const session = { sessionPrefix, seanceId };
setBrowser(null);
setPage(null);
setSessionPrefix(null);
setSeanceId(null);
return session;
}
/** Get current session state (for saving between reconnections). */
export function getSession() {
return { sessionPrefix, seanceId };
}
// ============================================================
// Multi-context support (used by run.mjs cmdTest only)
// ============================================================
/**
* Save current module-level state into the active slot before switching.
* No-op if no active slot.
*/
function saveActiveSlot() {
if (!activeContextName) return;
const slot = contexts.get(activeContextName);
if (!slot) return;
slot.page = page;
slot.sessionPrefix = sessionPrefix;
slot.seanceId = seanceId;
slot.highlightMode = highlightMode;
// Note: `recorder`, `lastCaptions`, `lastRecordingDuration` are intentionally NOT
// mirrored per-slot. A multi-context recording produces one continuous output file —
// the recorder follows the active page via recorder._attachPage(), not per-slot state.
}
/** Load a slot's state into module-level vars and mark it active. */
function activateSlot(name) {
const slot = contexts.get(name);
if (!slot) throw new Error(`Context "${name}" not found. Create it via createContext() first.`);
setPage(slot.page);
setSessionPrefix(slot.sessionPrefix);
setSeanceId(slot.seanceId);
setHighlightMode(slot.highlightMode || false);
setActiveContextName(name);
}
/** Attach 1C session listeners to a page, writing into the given slot. */
function attachSessionListeners(pg, slot, name) {
pg.on('dialog', dialog => dialog.accept().catch(() => {}));
pg.on('request', req => {
if (slot.seanceId) return;
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
if (m) {
slot.sessionPrefix = m[1];
slot.seanceId = m[2];
if (activeContextName === name) {
setSessionPrefix(m[1]);
setSeanceId(m[2]);
}
}
});
}
/**
* Create (or navigate) a named browser context.
* First call launches Chromium via chromium.launch() (NOT launchPersistentContext) so that
* subsequent calls can create additional isolated BrowserContexts in the same process.
* Trade-off: 1C browser extension is loaded via --load-extension (process-level) rather than
* persistent profile.
*
* Use this from run.mjs cmdTest only — exec/run/start use connect() and stay on the
* legacy persistent-context path.
*/
export async function createContext(name, url, { extensionPath, isolation = 'tab' } = {}) {
if (contexts.has(name)) {
await setActiveContext(name);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
catch { await page.waitForTimeout(5000); }
await closeModals();
return await getPageState();
}
if (!['tab', 'window'].includes(isolation)) {
throw new Error(`createContext: invalid isolation "${isolation}", expected 'tab' or 'window'`);
}
if (activeMode && activeMode !== isolation) {
throw new Error(`createContext: cannot mix isolation modes — first context used "${activeMode}", "${name}" requested "${isolation}". Use the same mode for all contexts in one run.`);
}
// First context: launch browser. Subsequent: reuse existing.
let isFirstContext = !browser;
if (isFirstContext) {
const extPath = findExtension(extensionPath);
const launchArgs = ['--start-maximized'];
if (extPath) {
launchArgs.push('--disable-extensions-except=' + extPath, '--load-extension=' + extPath);
}
if (isolation === 'tab') {
// Persistent context: extension loads reliably, one window with tabs per context
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-test-' + Date.now()));
mkdirSync(persistentUserDataDir, { recursive: true });
setBrowser(await chromium.launchPersistentContext(persistentUserDataDir, {
headless: false,
args: launchArgs,
viewport: null,
permissions: ['clipboard-read', 'clipboard-write'],
}));
} else {
// Window mode: separate BrowserContext per slot, full cookie isolation
setBrowser(await chromium.launch({ headless: false, args: launchArgs }));
}
setActiveMode(isolation);
}
// Save current active before switching
saveActiveSlot();
// Create slot — page differs by mode
let newCtx, newPage;
if (activeMode === 'tab') {
// Reuse the persistent context for all slots; each slot gets its own page (tab)
newCtx = browser;
if (isFirstContext) {
newPage = browser.pages()[0] || await browser.newPage();
} else {
newPage = await browser.newPage();
}
} else {
// Window mode: each slot owns its BrowserContext + page
newCtx = await browser.newContext({
viewport: null,
permissions: ['clipboard-read', 'clipboard-write'],
});
newPage = await newCtx.newPage();
}
const slot = {
context: newCtx,
page: newPage,
sessionPrefix: null,
seanceId: null,
highlightMode: false,
};
contexts.set(name, slot);
attachSessionListeners(newPage, slot, name);
activateSlot(name);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
catch { await page.waitForTimeout(5000); }
await closeModals();
return await getPageState();
}
/** Switch the active context. Subsequent browser API calls operate on this context's page. */
export async function setActiveContext(name) {
if (activeContextName === name) return;
if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
// If a recording is active, flush the outgoing page's last frame so the gap is filled
// up to the moment of the switch (avoids a "jump" in video time).
if (recorder && recorder._flushFrames) recorder._flushFrames();
saveActiveSlot();
activateSlot(name);
// If the recording is still alive (it lives across slots — we keep the same ffmpeg/output),
// re-attach its screencast to the newly active page.
if (recorder && recorder._attachPage) {
await recorder._attachPage(page);
}
}
export function listContexts() {
return [...contexts.keys()];
}
export function getActiveContext() {
return activeContextName;
}
export function hasContext(name) {
return contexts.has(name);
}
/**
* Close a named context: logout, close its page (tab mode) or BrowserContext
* (window mode), remove from registry. Cannot close the currently active
* context — caller must setActiveContext to another first. This keeps the
* recorder/page invariants simple: recorder is always attached to the
* active slot, which closeContext never touches.
*
* @throws if name is not registered or equals the active context.
*/
export async function closeContext(name) {
if (!contexts.has(name)) {
throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
}
if (name === activeContextName) {
throw new Error(`closeContext: cannot close the active context "${name}". setActiveContext to another context first.`);
}
const slot = contexts.get(name);
await logoutSlot(slot);
if (activeMode === 'tab') {
try { await slot.page.close(); } catch {}
} else {
try { await slot.context.close(); } catch {}
}
contexts.delete(name);
}
@@ -0,0 +1,113 @@
// web-test core/state v1.17 — module-level state for the web-test engine.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Holds the single browser/page/recorder slot plus the multi-context registry,
// constants, and small state-only utilities (ensureConnected, getPage,
// resolveProjectPath, normYo). Mutable values are exported as `let` bindings
// for live read access from consumer modules; writes go through setters so
// imported bindings stay read-only at the import site.
import { dirname, resolve as pathResolve } from 'path';
import { fileURLToPath } from 'url';
// Project root: 6 levels up from .claude/skills/web-test/scripts/engine/core/state.mjs
const __fn_state = fileURLToPath(import.meta.url);
export const projectRoot = pathResolve(dirname(__fn_state), '..', '..', '..', '..', '..', '..');
/** Resolve a user-provided path relative to the project root (not cwd). */
export const resolveProjectPath = (p) => pathResolve(projectRoot, p);
// ──────────────────────────────────────────────────────────────────────────
// Mutable single-session state. Importers read via the live binding; writes
// must go through the corresponding setter (ESM imports are read-only).
// ──────────────────────────────────────────────────────────────────────────
export let browser = null;
export let page = null;
export let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU"
export let seanceId = null;
export let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions }
export let lastCaptions = []; // captions from the last completed recording (for addNarration)
export let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds)
export let highlightMode = false;
export let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect
// Clipboard preservation: save full clipboard contents (all MIME types) right
// before each writeText+Ctrl+V pair, restore right after. Toggled via
// setPreserveClipboard() from run.mjs.
export let preserveClipboard = true;
export let clipboardWarnLogged = false;
export const setBrowser = (v) => { browser = v; };
export const setPage = (v) => { page = v; };
export const setSessionPrefix = (v) => { sessionPrefix = v; };
export const setSeanceId = (v) => { seanceId = v; };
export const setRecorder = (v) => { recorder = v; };
export const setLastCaptions = (v) => { lastCaptions = v; };
export const setLastRecordingDuration = (v) => { lastRecordingDuration = v; };
export const setHighlightMode = (v) => { highlightMode = !!v; };
export const setPersistentUserDataDir = (v) => { persistentUserDataDir = v; };
export const setPreserveClipboard = (v) => { preserveClipboard = !!v; };
export const setClipboardWarnLogged = (v) => { clipboardWarnLogged = !!v; };
// ──────────────────────────────────────────────────────────────────────────
// Multi-context registry: name → { context, page, sessionPrefix, seanceId,
// recorder, lastCaptions, lastRecordingDuration, highlightMode }.
// Populated by createContext(); module-level vars above mirror the active
// slot. connect() does NOT use this Map — it preserves legacy single-session
// behavior for exec/run/start.
// ──────────────────────────────────────────────────────────────────────────
export const contexts = new Map();
export let activeContextName = null;
// Isolation mode for the current cmdTest session — set by the first
// createContext call. 'tab': all contexts share one persistent context
// (one window, multiple tabs, extension loads reliably). 'window': each
// context gets its own BrowserContext (separate window per context, full
// cookie isolation, extension may not load).
export let activeMode = null;
export const setActiveContextName = (v) => { activeContextName = v; };
export const setActiveMode = (v) => { activeMode = v; };
// ──────────────────────────────────────────────────────────────────────────
// Constants.
// ──────────────────────────────────────────────────────────────────────────
export const LOAD_TIMEOUT = 60000;
export const INIT_TIMEOUT = 60000;
export const ACTION_WAIT = 2000; // fallback minimum wait
export const MAX_WAIT = 10000; // max wait for stability
export const POLL_INTERVAL = 200; // polling interval
export const STABLE_CYCLES = 3; // consecutive stable cycles needed
// 1C browser extension ID (stable across versions, defined by key in manifest.json)
export const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik';
// ──────────────────────────────────────────────────────────────────────────
// Utilities that only depend on state.
// ──────────────────────────────────────────────────────────────────────────
/** Normalize ё→е and  →space for fuzzy matching. */
export const normYo = (s) => s.replace(/ё/gi, 'е').replace(/ /g, ' ');
/** Check if browser is connected and page is usable. */
export function isConnected() {
if (!browser || !page || page.isClosed()) return false;
// launchPersistentContext returns BrowserContext (no isConnected), launch returns Browser
if (typeof browser.isConnected === 'function') return browser.isConnected();
// For persistent context, check via context's browser()
return browser.browser()?.isConnected() ?? false;
}
export function ensureConnected() {
if (!isConnected()) {
throw new Error('Browser not connected. Call web_connect first.');
}
}
/** Get the raw Playwright page object (for advanced scripting in skill mode). */
export function getPage() {
ensureConnected();
return page;
}
@@ -0,0 +1,123 @@
// web-test core/wait v1.17 — Smart wait helpers: DOM stability polling, JS-expression polling, CDP network monitor.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES } from './state.mjs';
import { detectFormScript } from '../../dom.mjs';
/**
* Smart wait: poll until DOM is stable and no loading indicators are visible.
* Checks: form number change, loading indicators, DOM stability.
* @param {number|null} previousFormNum — form number before the action (null = don't check)
*/
export async function waitForStable(previousFormNum = null) {
let stableCount = 0;
let lastSnapshot = '';
const start = Date.now();
while (Date.now() - start < MAX_WAIT) {
await page.waitForTimeout(POLL_INTERVAL);
// Check for loading indicators
const status = await page.evaluate(`(() => {
const loading = document.querySelector('.loadingImage, .waitCurtain, .progressBar');
const isLoading = loading && loading.offsetWidth > 0;
const formCount = document.querySelectorAll('input.editInput[id], a.press[id]').length;
return { isLoading, formCount };
})()`);
if (status.isLoading) {
stableCount = 0;
continue;
}
// Check DOM stability by comparing element count snapshot
const snapshot = String(status.formCount);
if (snapshot === lastSnapshot) {
stableCount++;
} else {
stableCount = 0;
lastSnapshot = snapshot;
}
// If form was expected to change, ensure it did
if (previousFormNum !== null && stableCount === 1) {
const currentForm = await page.evaluate(detectFormScript());
if (currentForm !== previousFormNum) {
// Form changed — still wait for stability
}
}
if (stableCount >= STABLE_CYCLES) return;
}
// Fallback: max wait reached
}
/**
* Start monitoring network activity via CDP.
* Must be called BEFORE the click so it captures all server requests.
* Returns a monitor object with waitDone() and cleanup() methods.
*/
export async function startNetworkMonitor() {
const client = await page.context().newCDPSession(page);
await client.send('Network.enable');
let pending = 0;
let total = 0;
let lastZeroTime = null;
const DEBOUNCE = 300;
client.on('Network.requestWillBeSent', () => {
pending++;
total++;
lastZeroTime = null;
});
client.on('Network.loadingFinished', () => {
if (--pending === 0) lastZeroTime = Date.now();
});
client.on('Network.loadingFailed', () => {
if (--pending === 0) lastZeroTime = Date.now();
});
return {
/** Wait until all network requests complete (300ms debounce) or UI element appears. */
async waitDone(timeout = 10000) {
const start = Date.now();
while (Date.now() - start < timeout) {
await page.waitForTimeout(50);
// Check for UI elements (modal, balloon, confirm)
const ui = await page.evaluate(`(() => {
const modal = document.querySelector('#modalSurface:not([style*="display: none"])');
const balloon = document.querySelector('.balloon');
const confirm = document.querySelector('.confirm');
return !!(modal || balloon || confirm);
})()`);
if (ui) return;
// CDP debounce: pending===0 held for DEBOUNCE ms
if (total > 0 && pending === 0 && lastZeroTime !== null) {
if (Date.now() - lastZeroTime >= DEBOUNCE) return;
}
}
},
/** Detach CDP session. Always call this when done. */
async cleanup() {
await client.send('Network.disable').catch(() => {});
await client.detach().catch(() => {});
}
};
}
/**
* Poll until a JS expression returns truthy, or timeout (ms) expires.
* Resolves early — typically within 100-300ms instead of fixed delays.
*/
export async function waitForCondition(evalScript, timeout = 2000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const result = await page.evaluate(evalScript);
if (result) return result;
await page.waitForTimeout(100);
}
return null;
}
@@ -0,0 +1,107 @@
// web-test forms/click-form v1.0 — click handler for form-element targets: button, tab, submenu, link.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Called by core/click.mjs dispatcher after target is found.
// Owns the CDP network-monitor lifecycle for button clicks (server roundtrip waits),
// post-click submenu detection (split buttons like "Создать на основании"),
// and confirmation hint propagation in the final state.
import { page, ACTION_WAIT } from '../core/state.mjs';
import {
detectFormScript, readSubmenuScript,
} from '../../dom.mjs';
import { checkForErrors } from '../core/errors.mjs';
import { waitForStable, startNetworkMonitor } from '../core/wait.mjs';
import { safeClick, returnFormState } from '../core/helpers.mjs';
/**
* Click a form target (button, tab, submenu, link) using its resolved {kind, id, x, y, name}.
* Handles three special concerns:
* 1. **netMonitor** for `kind: 'button'` — captures CDP requests started by the click
* so we can wait for them (when the form doesn't change) before stabilising.
* 2. **Submenu detection** — both pre-click (`kind: 'submenu'` already known) and
* post-click (split buttons like "Создать на основании" which open a popup).
* Returns `submenu[]` items as a hint for the caller.
* 3. **Confirmation propagation** — if a confirmation dialog opens as a result of the
* click, surface `confirmation` and `hint` fields on the returned state so the
* caller can react with Да/Нет/Отмена on the next call.
*/
export async function clickFormTarget(target, ctx) {
const { formNum, timeout } = ctx;
let netMonitor = null;
try {
// CDP network monitor BEFORE the click for buttons — captures all server requests
// triggered by the click so we can wait for them after.
if (target.kind === 'button') {
try { netMonitor = await startNetworkMonitor(); } catch {}
}
// Tabs without ID — use coordinate click to avoid global [data-content] ambiguity
if (target.kind === 'tab' && !target.id && target.x && target.y) {
await page.mouse.click(target.x, target.y);
} else {
const selector = `[id="${target.id}"]`;
// Use Playwright click for proper mousedown/mouseup events
await safeClick(selector, { timeout: 5000 });
}
// Pre-known submenu button — read popup items and return them as hints
if (target.kind === 'submenu') {
await page.waitForTimeout(ACTION_WAIT);
const submenuItems = await page.evaluate(readSubmenuScript());
const extras = { clicked: { kind: 'submenu', name: target.name } };
if (Array.isArray(submenuItems)) {
extras.submenu = submenuItems.map(i => i.name);
extras.hint = 'Call web_click again with a submenu item name to select it';
}
return returnFormState(extras);
}
await waitForStable(formNum);
// Check if the click opened a popup/submenu (split buttons like "Создать на основании")
const openedPopup = await page.evaluate(readSubmenuScript());
if (Array.isArray(openedPopup) && openedPopup.length > 0) {
return returnFormState({
clicked: { kind: 'submenu', name: target.name },
submenu: openedPopup.map(i => i.name),
hint: 'Call web_click again with a submenu item name to select it',
});
}
// For buttons that trigger server-side operations (post, write, etc.),
// the DOM may stabilise BEFORE the server response arrives.
// The CDP monitor (started before click) lets us wait for all in-flight requests
// to complete (300ms debounce) or for a modal/balloon/confirm to appear.
// Skip for grid edit mode (e.g. "Добавить" row) — no server round-trip expected.
if (target.kind === 'button') {
const postForm = await page.evaluate(detectFormScript());
if (postForm === formNum) {
const inGridEdit = await page.evaluate(`(() => {
const f = document.activeElement;
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; }
return false;
})()`);
if (!inGridEdit && netMonitor) {
await netMonitor.waitDone(timeout);
await waitForStable();
}
}
}
// Build final state with confirmation propagation
// (the one custom branch deliberately skipped by Phase 2 — surfaces confirmation
// + hint when a save/delete dialog opened as a result of the click).
const extras = { clicked: { kind: target.kind, name: target.name } };
const err = await checkForErrors();
if (err?.confirmation) {
extras.confirmation = err.confirmation;
extras.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond';
}
return returnFormState(extras);
} finally {
if (netMonitor) try { await netMonitor.cleanup(); } catch {}
}
}
@@ -0,0 +1,90 @@
// web-test forms/click-popup v1.0 — click handlers for in-form popups: confirmation dialogs and open submenus.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Both handlers run BEFORE clickElement's regular target-finding flow:
// - clickConfirmationButton intercepts when a pending confirmation dialog is open
// - tryClickPopupItem intercepts when a submenu/popup is open from a previous click
import { page, ACTION_WAIT, normYo } from '../core/state.mjs';
import { readSubmenuScript } from '../../dom.mjs';
import { waitForStable } from '../core/wait.mjs';
import { returnFormState } from '../core/helpers.mjs';
/**
* Click a button in the currently-open confirmation dialog (Да/Нет/Отмена, etc).
* Caller is responsible for verifying that a confirmation is actually pending
* (via checkForErrors().confirmation) before invoking this handler.
*
* Throws if no button matching `text` is found in the dialog.
*/
export async function clickConfirmationButton(text) {
const btnResult = await page.evaluate(`(() => {
const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || '';
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const target = ny(${JSON.stringify(text.toLowerCase())});
const btns = [...document.querySelectorAll('a.press.pressButton')].filter(el => el.offsetWidth > 0);
let best = btns.find(el => ny(norm(el.innerText).toLowerCase()) === target);
if (!best) best = btns.find(el => ny(norm(el.innerText).toLowerCase()).includes(target));
if (best) {
const r = best.getBoundingClientRect();
return { name: norm(best.innerText), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
}
return { error: 'not_found', available: btns.map(el => norm(el.innerText)).filter(Boolean) };
})()`);
if (btnResult?.error) {
throw new Error(`clickElement: "${text}" not found among confirmation buttons. Available: ${btnResult.available?.join(', ') || 'none'}`);
}
await page.mouse.click(btnResult.x, btnResult.y);
await waitForStable();
return returnFormState({ clicked: { kind: 'confirmation', name: btnResult.name } });
}
/**
* Try to click an item inside an already-open submenu/popup.
*
* Returns a form-state result on match (kind: 'popupItem' or 'submenuArrow'),
* or `null` if the requested text doesn't match any visible popup item — in
* which case the caller should fall through to regular form-element finding.
*
* @param {string} text — fuzzy-matched against item labels (NBSP/ё-normalised)
* @param {Array} popupItems — items already read via readSubmenuScript()
*/
export async function tryClickPopupItem(text, popupItems) {
const target = normYo(text.toLowerCase());
let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
if (!found) return null;
// submenuArrow items (group headers like "Создать", "Печать") — hover to expand nested submenu
if (found.kind === 'submenuArrow') {
// page.hover(selector) is more reliable than page.mouse.move(x,y) —
// some submenu groups don't expand with plain mouse.move
if (found.id) {
await page.hover(`[id="${found.id}"]`);
} else {
await page.mouse.move(found.x, found.y);
}
await page.waitForTimeout(ACTION_WAIT);
const nestedItems = await page.evaluate(readSubmenuScript());
const extras = { clicked: { kind: 'submenuArrow', name: found.name } };
if (Array.isArray(nestedItems)) {
extras.submenu = nestedItems.map(i => i.name);
extras.hint = 'Call web_click again with a submenu item name to select it';
}
return returnFormState(extras);
}
// Regular submenu/dropdown items — trusted events required.
// Use mouse.click(x,y) when in viewport; use :visible selector for clipped items
// (same ID can exist hidden in parent cloud AND visible in nested cloud).
const vpHeight = await page.evaluate('window.innerHeight');
if (found.x && found.y && found.y > 0 && found.y < vpHeight) {
await page.mouse.click(found.x, found.y);
} else if (found.id) {
await page.click(`[id="${found.id}"]:visible`);
} else if (found.x && found.y) {
await page.mouse.click(found.x, found.y);
}
await waitForStable();
return returnFormState({ clicked: { kind: 'popupItem', name: found.name } });
}
@@ -0,0 +1,56 @@
// web-test forms/close v1.18 — Close current form via Escape, handle save-changes confirmation.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page, recorder, ensureConnected } from '../core/state.mjs';
import { detectFormScript } from '../../dom.mjs';
import { dismissPendingErrors, checkForErrors, detectPlatformDialogs, closePlatformDialogs } from '../core/errors.mjs';
import { waitForStable } from '../core/wait.mjs';
import { returnFormState } from '../core/helpers.mjs';
import { getFormState } from './state.mjs';
/**
* Close the current form/dialog via Escape.
* @param {Object} [opts]
* @param {boolean} [opts.save] - Handle "Save changes?" confirmation automatically:
* true → click "Да" (save and close)
* false → click "Нет" (discard and close)
* undefined → return confirmation as hint for caller to decide
*/
export async function closeForm({ save } = {}) {
ensureConnected();
await dismissPendingErrors();
// If platform dialogs are open, close them instead of pressing Escape
const pd = await detectPlatformDialogs();
if (pd.length) {
await closePlatformDialogs();
await page.waitForTimeout(300);
return returnFormState({ closed: true, closedPlatformDialogs: pd });
}
const beforeForm = await page.evaluate(detectFormScript());
await page.keyboard.press('Escape');
await waitForStable(beforeForm);
const state = await getFormState();
const err = await checkForErrors();
if (err?.confirmation) {
if (save === true || save === false) {
const label = save ? 'Да' : 'Нет';
const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`;
const btns = await page.$$(btnSel);
for (const b of btns) {
const txt = (await b.textContent()).trim();
if (txt === label) {
if (recorder) await page.waitForTimeout(500); // show confirmation to viewer during recording
await b.click({ force: true });
await waitForStable(beforeForm);
break;
}
}
const afterForm = await page.evaluate(detectFormScript());
return returnFormState({ closed: afterForm !== beforeForm });
}
state.confirmation = err.confirmation;
state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel';
return state;
}
return returnFormState({ closed: state.form !== beforeForm });
}
@@ -0,0 +1,146 @@
// web-test forms/fill v1.18 — Fill form fields by name (text/checkbox/date/dropdown/reference). Delegates references to selectValue / fillReferenceField.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import {
page, ensureConnected, ACTION_WAIT, highlightMode, normYo,
} from '../core/state.mjs';
import {
detectFormScript, resolveFieldsScript,
} from '../../dom.mjs';
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
import { waitForStable, startNetworkMonitor } from '../core/wait.mjs';
import { highlight, unhighlight } from '../recording/highlight.mjs';
import {
fillReferenceField, selectValue, pickFromSelectionForm,
isTypeDialog, pickFromTypeDialog,
} from './select-value.mjs';
import { pasteText } from '../core/clipboard.mjs';
import { returnFormState } from '../core/helpers.mjs';
/** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */
export async function fillFields(fields) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('fillFields: no form found');
// Resolve field names to element IDs
const resolved = await page.evaluate(resolveFieldsScript(formNum, fields));
const results = [];
for (const r of resolved) {
if (r.error) {
results.push(r);
continue;
}
// Auto-highlight the field input before filling
if (highlightMode && r.inputId) {
try {
await page.evaluate(({ id }) => {
const target = document.getElementById(id);
if (!target) return;
let div = document.getElementById('__web_test_highlight');
if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
const r = target.getBoundingClientRect();
div.style.cssText = 'position:fixed;pointer-events:none;z-index:999998;top:' + (r.y-4) + 'px;left:' + (r.x-4) + 'px;width:' + (r.width+8) + 'px;height:' + (r.height+8) + 'px;outline:3px solid #e74c3c;border-radius:4px;box-shadow:0 0 16px #e74c3c80';
}, { id: r.inputId });
await page.waitForTimeout(500);
await unhighlight();
} catch {}
}
try {
// Auto-enable DCS checkbox if resolved via label
if (r.dcsCheckbox && !r.dcsCheckbox.checked) {
await page.click(`[id="${r.dcsCheckbox.inputId}"]`);
await waitForStable();
}
const selector = `[id="${r.inputId}"]`;
// Clear field via Shift+F4 if value is empty (not applicable to checkbox/radio)
const rawValue = fields[r.field];
const isEmpty = rawValue === '' || rawValue === null || rawValue === undefined;
if (isEmpty && !r.isCheckbox && !r.isRadio) {
await page.click(selector);
await page.waitForTimeout(200);
await page.keyboard.press('Shift+F4');
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await waitForStable();
results.push({ field: r.field, ok: true, value: '', method: 'clear' });
continue;
}
if (r.isCheckbox) {
// Checkbox: compare desired with current, toggle if mismatch
const desired = String(fields[r.field]).toLowerCase();
const wantChecked = ['true', '1', 'да', 'yes', 'on'].includes(desired);
if (wantChecked !== r.checked) {
await page.click(selector);
await waitForStable();
}
results.push({ field: r.field, ok: true, value: String(wantChecked), method: 'toggle' });
} else if (r.isRadio) {
// Radio button: find option by label (fuzzy match) and click it
const desired = normYo(String(fields[r.field]).toLowerCase());
const opt = r.options.find(o => normYo(o.label.toLowerCase()) === desired)
|| r.options.find(o => normYo(o.label.toLowerCase()).includes(desired));
if (opt) {
// Option 0 = base element (no suffix), options 1+ = #N#radio
const radioId = opt.index === 0 ? r.inputId : `${r.inputId}#${opt.index}#radio`;
await page.click(`[id="${radioId}"]`);
await waitForStable();
results.push({ field: r.field, ok: true, value: opt.label, method: 'radio' });
} else {
results.push({ field: r.field, error: 'option_not_found', available: r.options.map(o => o.label) });
}
} else if (r.hasSelect) {
// Combobox/reference with DLB: DLB-first, then paste fallback
const refResult = await fillReferenceField(selector, r.field, fields[r.field], formNum);
results.push(refResult);
} else if (r.hasPick && r.isDate) {
// Date/time field with calendar CB — use paste (calendar is not a selection form)
await page.click(selector);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await pasteText(fields[r.field]);
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await waitForStable();
results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' });
} else if (r.hasPick) {
// Reference field with CB (non-editable or editable ref): delegate to selectValue (F4 → selection form)
const svResult = await selectValue(r.field, String(fields[r.field]));
if (svResult?.error) {
results.push({ field: r.field, error: svResult.error, message: svResult.message });
} else {
results.push({ field: r.field, ok: true, value: svResult.value || String(fields[r.field]), method: svResult.method || 'form' });
}
} else {
// Plain field: clipboard paste + Tab to commit
// page.fill() sets DOM value but doesn't trigger 1C input events;
// clipboard paste (Ctrl+V) is a trusted event that 1C processes correctly.
await page.click(selector);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await pasteText(fields[r.field]);
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await waitForStable();
results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' });
}
} catch (e) {
results.push({ field: r.field, error: e.message });
}
if (highlightMode) try { await unhighlight(); } catch {}
}
const failed = results.filter(r => r.error);
if (failed.length > 0) {
const details = failed.map(f => ` ${f.field}: ${f.message || f.error}${f.available ? ' (available: ' + f.available.join(', ') + ')' : ''}`).join('\n');
throw new Error(`fillFields: ${failed.length} of ${results.length} field(s) failed:\n${details}`);
}
return returnFormState({ filled: results });
}
/** Convenience alias: fill a single field. Same as fillFields({ name: value }). */
export async function fillField(name, value) {
return fillFields({ [name]: value });
}
@@ -0,0 +1,789 @@
// web-test forms/select-value v1.20 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import {
page, ensureConnected, normYo, highlightMode, ACTION_WAIT,
} from '../core/state.mjs';
import {
detectFormScript, findFieldButtonScript, resolveFieldsScript,
readSubmenuScript, checkErrorsScript,
findSearchInputScript, findNamedButtonScript, findCompareTypeRadioScript, isFormVisibleScript,
findPatternInputIdScript, isTypeDialogScript, isNotInListCloudVisibleScript,
findChildFormByButtonScript, readTypeDialogVisibleRowsScript,
} from '../../dom.mjs';
import { scanGridRowsScript } from '../../dom/grid.mjs';
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
import { waitForStable, waitForCondition } from '../core/wait.mjs';
import { highlight, unhighlight } from '../recording/highlight.mjs';
import {
safeClick, findFieldInputId, readEdd,
detectNewForm as helperDetectNewForm,
clickEddItemViaDispatch, clickShowAllInEdd, returnFormState,
} from '../core/helpers.mjs';
import { pasteText } from '../core/clipboard.mjs';
import { getFormState } from './state.mjs';
/**
* Scan visible grid rows for a text match (exact → startsWith → includes).
* Returns center coords of the matched row, or null if not found.
* When searchLower is empty, returns coords of the first row (fallback).
*/
async function scanGridRows(formNum, searchLower) {
return page.evaluate(scanGridRowsScript(formNum, searchLower));
}
/**
* Select a row in a selection form via click + Enter, verify it closed.
* Uses click + Enter instead of dblclick because dblclick toggles
* expand/collapse in tree-style selection forms.
* Returns { field, ok: true, method: 'form' } on success,
* or { field, ok: false, reason: 'still_open' } if the item couldn't be selected (e.g. group row).
*/
async function dblclickAndVerify(coords, selFormNum, fieldName) {
// Click to highlight the row, then Enter to confirm selection.
// This works for both flat grids and tree forms (dblclick would
// toggle expand/collapse on tree group rows).
await page.mouse.click(coords.x, coords.y);
await page.waitForTimeout(200);
await page.keyboard.press('Enter');
await waitForStable(selFormNum);
// Verify selection form closed
const stillOpen = await page.evaluate(isFormVisibleScript(selFormNum));
if (stillOpen) {
// Enter didn't select — item is likely a non-selectable group.
// Don't Escape here — let the caller decide (may want to try another row).
return { field: fieldName, ok: false, reason: 'still_open' };
}
// Check for 1C error modals after selection
const err = await page.evaluate(checkErrorsScript());
if (err?.modal) {
try {
const btn = await page.$('a.press.pressDefault');
if (btn) { await btn.click(); await page.waitForTimeout(500); }
} catch { /* OK */ }
}
return { field: fieldName, ok: true, method: 'form' };
}
/**
* Inline advanced search on a selection form via Alt+F.
* Does NOT click any column — FieldSelector auto-populates with main representation.
* Switches to "по части строки" (CompareType#1) to avoid composite type issues.
* Does not throw — returns silently on failure.
*/
async function advancedSearchInline(formNum, text) {
try {
// 1. Open advanced search via Alt+F
await page.keyboard.press('Alt+f');
await page.waitForTimeout(2000);
const dialogForm = await page.evaluate(detectFormScript());
if (dialogForm === formNum || dialogForm === null) return; // Alt+F didn't open dialog
// 2. Switch to "по части строки" (CompareType#1)
const radioClicked = await page.evaluate(findCompareTypeRadioScript(dialogForm, 1));
if (radioClicked && !radioClicked.already) {
await page.mouse.click(radioClicked.x, radioClicked.y);
await page.waitForTimeout(300);
}
// 3. Fill Pattern field via clipboard paste
const patternId = await page.evaluate(findPatternInputIdScript(dialogForm));
if (!patternId) {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
return;
}
await page.click(`[id="${patternId}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await pasteText(text);
await page.waitForTimeout(300);
// 4. Click "Найти"
const findBtn = await page.evaluate(findNamedButtonScript('Найти'));
if (findBtn) {
await page.mouse.click(findBtn.x, findBtn.y);
await page.waitForTimeout(2000);
}
// 5. Close advanced search dialog
for (let attempt = 0; attempt < 3; attempt++) {
const dialogVisible = await page.evaluate(isFormVisibleScript(dialogForm));
if (!dialogVisible) break;
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
}
await waitForStable(formNum);
} catch { /* silently fail — caller will re-scan and handle not_found */ }
}
/**
* Pick a value from an opened selection form.
*
* Strategy (escalating):
* 1. Scan visible rows for text match (exact → startsWith → includes)
* 2. Advanced search (Alt+F, "по части строки") → re-scan
* 3. Fallback: simple search (search input + Enter) → re-scan
* 4. Not found → Escape → error
*
* For object search {field: value}: steps 1, then filterList(val, {field}) per entry, then re-scan.
* For empty search: pick first visible row.
*
* @param {number} selFormNum - selection form number
* @param {string} fieldName - field being filled (for error messages)
* @param {string|Object} search - string for simple search, or { field: value } for per-field search
* @param {number} origFormNum - original form number (to verify we returned)
* @returns {{ field, ok, method }} or {{ field, error, message }}
*/
export async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) {
const searchText = typeof search === 'string'
? search : (search ? Object.values(search).join(' ') : '');
const searchLower = normYo((searchText || '').toLowerCase());
// Helper: try to select a row; returns result if ok, null if item wasn't selectable (group).
let hadUnselectableMatch = false;
async function trySelect(row) {
const r = await dblclickAndVerify(row, selFormNum, fieldName);
if (r.ok) return r;
hadUnselectableMatch = true; // found match but couldn't select (possibly group row or overlay)
return null; // form still open, try next step
}
// Step 1: Scan visible rows (no filtering)
if (searchLower) {
const row = await scanGridRows(selFormNum, searchLower);
if (row?.x) {
const r = await trySelect(row);
if (r) return r;
}
}
// Step 2: Advanced search (Alt+F — fast, no overlay issues)
if (typeof search === 'object' && search) {
// Per-field advanced search via filterList(val, {field})
for (const [fld, val] of Object.entries(search)) {
try { await filterList(String(val), { field: fld }); } catch { /* proceed */ }
}
} else if (searchLower) {
// Inline advanced search (Alt+F, "по части строки")
await advancedSearchInline(selFormNum, searchText);
}
if (searchLower) {
const row = await scanGridRows(selFormNum, searchLower);
if (row?.x) {
const r = await trySelect(row);
if (r) return r;
}
}
// Step 3: Fallback — simple search via search input (for forms without Alt+F support)
if (typeof search === 'string' && searchLower) {
const searchInputInfo = await page.evaluate(findSearchInputScript(selFormNum));
if (searchInputInfo) {
try {
await page.click(`[id="${searchInputInfo.id}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await pasteText(searchText);
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await waitForStable(selFormNum);
} catch { /* proceed */ }
const row = await scanGridRows(selFormNum, searchLower);
if (row?.x) {
const r = await trySelect(row);
if (r) return r;
}
}
}
// Step 4: Empty search → pick first row; otherwise not found
if (!searchLower) {
const row = await scanGridRows(selFormNum, '');
if (row?.x) {
const r = await trySelect(row);
if (r) return r;
}
}
await page.keyboard.press('Escape');
await waitForStable();
const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search);
if (hadUnselectableMatch) {
return { field: fieldName, error: 'not_selectable',
message: 'Found ' + searchDesc + ' in selection form but it is not selectable (group/folder row)' };
}
return { field: fieldName, error: 'not_found',
message: 'No matches in selection form for ' + searchDesc };
}
/**
* Detect whether a form is a type selection dialog ("Выбор типа данных").
* Type dialogs appear when selecting a value for a composite-type field.
*
* Detection signals (any one is sufficient):
* - form{N}_OK element exists (selection forms use "Выбрать", not "OK")
* - form{N}_ValueList grid exists (specific to type/value list dialogs)
* - Window title contains "Выбор типа" (title attr on .toplineBoxTitle)
*/
export async function isTypeDialog(formNum) {
return page.evaluate(isTypeDialogScript(formNum));
}
/**
* Select a type from the type selection dialog ("Выбор типа данных")
* using Ctrl+F search. The dialog has a virtual grid (~5 visible rows),
* so Ctrl+F is the only reliable way to find a type.
*
* Algorithm: Ctrl+F → paste typeName → Enter (search) → Escape (close Find) →
* verify selected row matches → Enter (OK)
*
* @param {number} formNum - type dialog form number
* @param {string} typeName - type name to search for (fuzzy, e.g. "Реализация (акт")
* @throws {Error} if type not found
*/
export async function pickFromTypeDialog(formNum, typeName) {
// The type dialog is a modal ValueList grid.
// Strategy: scan visible rows first (fast path), fall back to Ctrl+F for large lists.
//
// Key constraints discovered during testing:
// - Grid focus: use evaluate(() => gridBody.focus()), NOT page.click({force:true})
// which punches through the modal overlay to the form underneath
// - Ctrl+F only opens "Найти" if the GRID is focused (otherwise closes the type dialog)
// - Buttons: use page.click({force:true}), NOT evaluate(() => el.click())
// because evaluate click doesn't trigger 1C's event chain properly
// - Enter/Escape in "Найти" close the ENTIRE dialog chain, not just "Найти"
// - Closing "Найти" via Cancel resets the search — verify grid while "Найти" is open
const typeNorm = normYo(typeName.toLowerCase());
// Helper: read visible rows and find matching ones
async function readVisibleRows() {
return page.evaluate(readTypeDialogVisibleRowsScript(formNum, typeNorm));
}
// Step 1: Scan visible rows (fast path — no Ctrl+F needed for small lists)
const scan = await readVisibleRows();
if (scan.matches.length === 1) {
// Single match — click to select, then OK
await page.mouse.click(scan.matches[0].x, scan.matches[0].y);
await page.waitForTimeout(200);
await page.click(`#form${formNum}_OK`, { force: true });
await page.waitForTimeout(ACTION_WAIT);
return;
}
if (scan.matches.length > 1) {
for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); }
await waitForStable();
throw new Error(`selectValue: multiple types match "${typeName}": ${scan.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`);
}
// Step 2: Not found in visible rows — use Ctrl+F (virtual grid may have more items)
// Focus the grid via evaluate (does NOT punch through modal like page.click)
await page.evaluate(`(() => {
const grid = document.getElementById('form${formNum}_ValueList');
if (!grid) return;
const body = grid.querySelector('.gridBody');
if (body) body.focus(); else grid.focus();
})()`);
await page.waitForTimeout(300);
// Ctrl+F to open "Найти" dialog
await page.keyboard.press('Control+f');
await page.waitForTimeout(1000);
// Paste search text (focus is on "Что искать" field)
await page.keyboard.press('Control+a');
await pasteText(typeName);
await page.waitForTimeout(300);
// Find the "Найти" dialog form number (it's > formNum)
const findFormNum = await page.evaluate(findChildFormByButtonScript(formNum, 'Find'));
if (findFormNum === null) {
await page.keyboard.press('Escape');
await waitForStable();
throw new Error('selectValue: Ctrl+F did not open "Найти" dialog in type selection');
}
// Click "Найти" — search is client-side (no server round-trip), 500ms is enough
await page.click(`#form${findFormNum}_Find`, { force: true });
await page.waitForTimeout(500);
// Re-read visible rows after search scrolled to match
const afterSearch = await readVisibleRows();
if (afterSearch.matches.length === 0) {
for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); }
await waitForStable();
throw new Error(`selectValue: type "${typeName}" not found in type selection dialog` +
`. Visible: ${(scan.visible || []).join(', ')}`);
}
if (afterSearch.matches.length > 1) {
for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); }
await waitForStable();
throw new Error(`selectValue: multiple types match "${typeName}": ${afterSearch.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`);
}
// Click OK on type dialog via page.click({force:true}) — bypasses "Найти" modal
await page.click(`#form${formNum}_OK`, { force: true });
await page.waitForTimeout(ACTION_WAIT);
}
/**
* Fill a reference field via clipboard paste + 1C autocomplete.
*
* Strategy:
* 1. Clear field if it has a value (Shift+F4 — native 1C mechanism, no JS errors)
* 2. Clipboard paste text (Ctrl+V = trusted event, triggers real 1C autocomplete)
* 3. Check editDropDown for autocomplete results → click match or Tab to resolve
* 4. Verify result: resolved → ok, not found → clear + error
*
* Clipboard paste was chosen because:
* - Ctrl+V produces trusted browser events that 1C respects for autocomplete
* - page.fill() + synthetic keydown/keyup only triggers hints, not real search
* - keyboard.type() garbles Cyrillic on some fields
*
* @returns {{ field, ok?, method?, error?, value?, message?, available? }}
*/
export async function fillReferenceField(selector, fieldName, value, formNum) {
const text = String(value);
const escapedSel = selector.replace(/'/g, "\\'");
// Helper: detect new forms opened above the current one (strict — interactive
// elements only; fillReferenceField-specific)
const detectNewForm = () => helperDetectNewForm(formNum, { strict: true });
// Helper: clear the field using Shift+F4 (native 1C mechanism)
async function clearField() {
try {
await page.click(selector, { timeout: 3000 });
await page.keyboard.press('Shift+F4');
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await page.waitForTimeout(300);
} catch { /* OK */ }
}
// Helper: check for "not in list" cloud popup (1C shows positioned div with "нет в списке")
async function checkNotInListCloud() {
return page.evaluate(isNotInListCloudVisibleScript());
}
// 0. Dismiss any leftover error modal from a previous operation
await dismissPendingErrors();
// 0a. Try DLB (DropListButton) first — works cleanly for combobox/enum fields
// and also for reference fields that show a dropdown.
const inputId = selector.match(/\[id="(.+)"\]/)?.[1];
// DLB button ID uses field name without _iN suffix (e.g. form1_Field_DLB, not form1_Field_i0_DLB)
const dlbId = inputId.replace(/_i\d+$/, '') + '_DLB';
const dlbSelector = `[id="${dlbId}"]`;
try {
const dlbVisible = await page.evaluate(`document.querySelector('${dlbSelector.replace(/'/g, "\\'")}')?.offsetWidth > 0`);
if (dlbVisible) {
await page.click(dlbSelector);
await page.waitForTimeout(1000);
const eddState = await readEdd();
if (eddState.visible && eddState.items?.length > 0) {
const target = normYo(text.toLowerCase());
const candidates = eddState.items.filter(i => !i.name.startsWith('Создать'));
let match = candidates.find(i => normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === target);
if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target));
if (!match) match = candidates.find(i => {
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
return name.includes(target) || target.includes(name);
});
if (match) {
await page.mouse.click(match.x, match.y);
await waitForStable();
await dismissPendingErrors();
return { field: fieldName, ok: true, method: 'dropdown',
value: match.name.replace(/\s*\([^)]*\)\s*$/, '') };
}
// No match in DLB dropdown — close and fall through to paste approach
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
} else if (eddState.visible) {
// DLB opened a hint popup (no .eddText items) — close it before proceeding
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
}
}
} catch { /* DLB approach failed — fall through to paste */ }
// 1. Focus (handle surface/modal overlay from previous interaction)
await safeClick(selector, { dismissErrors: true });
// 2. If field already has a value, clear using Shift+F4 (native 1C mechanism).
// This is needed for reference fields — Shift+F4 properly clears the ref link.
const currentVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`);
if (currentVal) {
await page.keyboard.press('Shift+F4');
await page.waitForTimeout(500);
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
// Refocus
await page.click(selector);
}
// 3. Paste text via clipboard (trusted event → triggers real 1C autocomplete)
await pasteText(text);
await page.waitForTimeout(2000);
// 4. Check editDropDown for autocomplete suggestions
const eddState = await readEdd();
if (eddState.visible && eddState.items?.length > 0) {
const target = normYo(text.toLowerCase());
// Separate real matches from "Создать:" items
const candidates = eddState.items.filter(i => !i.name.startsWith('Создать'));
if (candidates.length > 0) {
// Find best match (items have format "Name (Code)" — match against name part)
let match = candidates.find(i => {
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
return name === target;
});
if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target));
if (!match) match = candidates.find(i => {
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
return name.includes(target) || target.includes(name);
});
if (match) {
await page.mouse.click(match.x, match.y);
await waitForStable();
await dismissPendingErrors(); // business logic errors (e.g. СПАРК) may appear async
return { field: fieldName, ok: true, method: 'dropdown',
value: match.name.replace(/\s*\([^)]*\)\s*$/, '') };
}
// Candidates exist but none match — report them
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await clearField();
return { field: fieldName, error: 'not_matched',
available: candidates.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) };
}
// Only "Создать:" items — no existing matches
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await clearField();
return { field: fieldName, error: 'not_found',
message: 'No existing values match "' + text + '"' };
}
// 4b. No edd — check for "not in list" cloud that may have appeared during paste
if (await checkNotInListCloud()) {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await clearField();
return { field: fieldName, error: 'not_found',
message: 'Value "' + text + '" not found (not in list)' };
}
// 5. No edd at all — press Tab to trigger direct resolve
await page.keyboard.press('Tab');
await waitForStable();
await dismissPendingErrors();
// 5x. Check for "not in list" cloud popup after Tab
if (await checkNotInListCloud()) {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await clearField();
return { field: fieldName, error: 'not_found',
message: 'Value "' + text + '" not found (not in list)' };
}
// 5a. New form opened? (creation form = value not found)
const newForm = await detectNewForm();
if (newForm !== null) {
await page.keyboard.press('Escape');
await waitForStable();
await clearField();
return { field: fieldName, error: 'not_found',
message: 'Value "' + text + '" not found' };
}
// 5b. Dropdown after Tab?
const popup = await page.evaluate(readSubmenuScript());
if (Array.isArray(popup) && popup.length > 0) {
const realItems = popup.filter(i => !i.name.startsWith('Создать'));
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await clearField();
if (realItems.length > 0) {
return { field: fieldName, error: 'ambiguous',
message: 'Multiple matches for "' + text + '"',
available: realItems.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) };
}
return { field: fieldName, error: 'not_found',
message: 'Value "' + text + '" not found' };
}
// 5c. Check final value
const finalVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`);
if (!finalVal) {
// 6. Last resort: try F4 to open selection form and pick from there
try {
await page.click(selector);
await page.waitForTimeout(300);
} catch { /* OK — field may be unfocused */ }
await page.keyboard.press('F4');
await page.waitForTimeout(ACTION_WAIT);
const selFormNum = await detectNewForm();
if (selFormNum !== null) {
const pickResult = await pickFromSelectionForm(selFormNum, fieldName, text, formNum);
if (pickResult.ok) return pickResult;
// pickFromSelectionForm already closed the form on error
}
return { field: fieldName, error: 'not_found',
message: 'Value "' + text + '" not found (field is empty)' };
}
return { field: fieldName, ok: true, method: 'typeahead', value: finalVal };
}
/**
* Select a value from a reference field (compound operation).
* Handles three patterns:
* A) DLB opens an inline dropdown popup — click matching item
* B) DLB opens dropdown with history — click "Показать все" or F4 to open selection form
* C) DLB opens a separate selection form directly — search + dblclick in grid
*/
export async function selectValue(fieldName, searchText, { type } = {}) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error(`selectValue: no form found`);
// Detect any new form opened above this one (broad — includes type dialogs).
// Hoisted to the top so the composite-type branch can call it before its
// original declaration site further below.
const detectNewForm = () => helperDetectNewForm(formNum);
// 1. Find DLB button (fallback to CB — ERP uses Choose Button instead of DLB for some fields)
let btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'DLB'));
if (btn?.error === 'button_not_found') {
btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'CB'));
}
if (btn?.error) return btn;
if (highlightMode) try { await highlight(fieldName); await page.waitForTimeout(500); await unhighlight(); } catch {}
try {
// === CLEAR FIELD if searchText is empty/null ===
if (!searchText && searchText !== 0) {
const inputId = await findFieldInputId(formNum, btn.fieldName);
if (inputId) {
await page.click(`[id="${inputId}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Shift+F4');
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await waitForStable();
}
if (highlightMode) try { await unhighlight(); } catch {}
return returnFormState({ selected: { field: fieldName, search: null, method: 'clear' } });
}
// === COMPOSITE TYPE HANDLING ===
// When `type` is specified, clear the field first to reset cached type,
// then open type selection dialog, pick the type, then pick the value.
if (type) {
// Find and focus the field input
const inputId = await findFieldInputId(formNum, btn.fieldName);
if (!inputId) throw new Error(`selectValue: field "${btn.fieldName}" input not found`);
// Clear cached type + value with Shift+F4
await page.click(`[id="${inputId}"]`);
await page.waitForTimeout(300);
await page.keyboard.press('Shift+F4');
await page.waitForTimeout(500);
// Re-focus and press F4 to open type selection dialog
await page.click(`[id="${inputId}"]`);
await page.waitForTimeout(300);
await page.keyboard.press('F4');
await page.waitForTimeout(ACTION_WAIT);
await waitForStable(formNum);
const newFormNum = await detectNewForm();
if (newFormNum === null) {
throw new Error(`selectValue: F4 for composite field "${btn.fieldName}" did not open type selection dialog`);
}
if (await isTypeDialog(newFormNum)) {
// Pick type from the dialog
await pickFromTypeDialog(newFormNum, type);
await waitForStable(newFormNum);
// After type selection, the actual selection form should open
const selFormNum = await detectSelectionForm();
if (selFormNum === null) {
throw new Error(`selectValue: after selecting type "${type}", no selection form opened for "${btn.fieldName}"`);
}
const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
const state = await getFormState();
state.selected = { field: btn.fieldName, search: searchText || null, type, method: 'form' };
if (pickResult.error) state.selected.error = pickResult.error;
if (pickResult.message) state.selected.message = pickResult.message;
const err = await checkForErrors();
if (err) state.errors = err;
return state;
} else {
// Not a type dialog — field is not composite type, proceed with normal selection
const pickResult = await pickFromSelectionForm(newFormNum, btn.fieldName, searchText || '', formNum);
const state = await getFormState();
state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
if (pickResult.error) state.selected.error = pickResult.error;
if (pickResult.message) state.selected.message = pickResult.message;
const err = await checkForErrors();
if (err) state.errors = err;
return state;
}
}
// === END COMPOSITE TYPE HANDLING ===
// Auto-enable DCS checkbox if resolved via label
if (btn.dcsCheckbox) {
const cbSel = `[id="${btn.dcsCheckbox.inputId}"]`;
const isChecked = await page.$eval(cbSel, el =>
el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'));
if (!isChecked) { await page.click(cbSel); await waitForStable(); }
}
// Helper: detect selection form (form number > formNum, strict mode)
async function detectSelectionForm() {
return helperDetectNewForm(formNum, { strict: true });
}
// detectNewForm is hoisted at the top of selectValue (see above).
// Helper: open selection form and pick value
async function openFormAndPick() {
await waitForStable(formNum);
const selFormNum = await detectSelectionForm();
if (selFormNum !== null) {
const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
const selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
if (pickResult.error) selected.error = pickResult.error;
if (pickResult.message) selected.message = pickResult.message;
return returnFormState({ selected });
}
return null;
}
// Locals → dom-scripts in helpers.mjs (see clickEddItemViaDispatch / clickShowAllInEdd)
const clickEddItem = clickEddItemViaDispatch;
const clickShowAll = clickShowAllInEdd;
// 2. Click DLB (handle funcPanel / surface overlay intercept)
const dlbSel = `[id="${btn.buttonId}"]`;
await safeClick(dlbSel, { timeout: 5000 });
await page.waitForTimeout(ACTION_WAIT);
// 3A. Check if a dropdown popup appeared (inline quick selection)
const popupItems = await page.evaluate(readSubmenuScript());
if (Array.isArray(popupItems) && popupItems.length > 0) {
const regularItems = popupItems.filter(i => i.kind !== 'showAll');
const showAllItem = popupItems.find(i => i.kind === 'showAll');
if (searchText) {
const target = normYo(searchText.toLowerCase());
// Try to find match among regular dropdown items
let match = regularItems.find(i => normYo(i.name.toLowerCase()) === target);
if (!match) match = regularItems.find(i => normYo(i.name.toLowerCase()).includes(target));
if (!match) match = regularItems.find(i => {
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
return name === target || name.includes(target) || target.includes(name);
});
if (match) {
// Click via evaluate to bypass div.surface overlay
await clickEddItem(match.name);
await waitForStable();
return returnFormState({ selected: { field: btn.fieldName, search: searchText, method: 'dropdown' } });
}
// No match in dropdown — try "Показать все" to open selection form
if (showAllItem) {
await clickShowAll();
const formResult = await openFormAndPick();
if (formResult) return formResult;
}
// No "Показать все" — close dropdown, try F4
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
// Focus the field input and press F4 to open selection form
const inputId = await findFieldInputId(formNum, btn.fieldName);
if (inputId) {
await page.click(`[id="${inputId}"]`);
await page.waitForTimeout(300);
}
await page.keyboard.press('F4');
await page.waitForTimeout(ACTION_WAIT);
const formResult = await openFormAndPick();
if (formResult) return formResult;
// Still nothing — report available items from original dropdown
throw new Error(`selectValue: "${searchText}" not found for field "${btn.fieldName}". Available: ${regularItems.map(i => i.name).join(', ') || 'none'}`);
}
// No search text — click first regular item
if (regularItems.length > 0) {
await clickEddItem(regularItems[0].name);
await waitForStable();
return returnFormState({ selected: { field: btn.fieldName, search: null, picked: regularItems[0].name, method: 'dropdown' } });
}
}
// 3B. Check if a new selection form opened directly (use broad detection to also catch type dialogs)
const selFormNum = await detectNewForm();
if (selFormNum !== null) {
// Auto-detect type selection dialog when `type` was not specified
if (await isTypeDialog(selFormNum)) {
await page.keyboard.press('Escape');
await waitForStable();
throw new Error(`selectValue: field "${btn.fieldName}" opened a type selection dialog — this is a composite-type field. Specify the type: selectValue('${btn.fieldName}', '${searchText || ''}', { type: 'ИмяТипа' })`);
}
const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
const selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
if (pickResult.error) selected.error = pickResult.error;
if (pickResult.message) selected.message = pickResult.message;
return returnFormState({ selected });
}
// 3C. Neither popup nor form — try F4 as last resort
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
const inputId = await findFieldInputId(formNum, btn.fieldName);
if (inputId) {
await page.click(`[id="${inputId}"]`);
await page.waitForTimeout(300);
}
await page.keyboard.press('F4');
await page.waitForTimeout(ACTION_WAIT);
const formResult = await openFormAndPick();
if (formResult) return formResult;
throw new Error(`selectValue: DLB click for "${btn.fieldName}" did not open a popup or selection form`);
} finally { if (highlightMode) try { await unhighlight(); } catch {} }
}
@@ -0,0 +1,32 @@
// web-test engine/forms/state v1.17 — central form-state reader.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// getFormState — the canonical "what's on the screen right now" call. Combines:
// 1. DOM script (getFormStateScript) → form structure (fields, buttons, tables, openForms, ...)
// 2. checkForErrors → state.errors + state.confirmation hint
// 3. detectPlatformDialogs → state.platformDialogs (About / Support Info / Error Report)
//
// Returned by virtually every action-function as the "after" snapshot.
import { page, ensureConnected } from '../core/state.mjs';
import { getFormStateScript } from '../../dom.mjs';
import { checkForErrors, detectPlatformDialogs } from '../core/errors.mjs';
/** Read current form state. Single evaluate call via combined script. */
export async function getFormState() {
ensureConnected();
const state = await page.evaluate(getFormStateScript());
const err = await checkForErrors();
if (err) {
state.errors = err;
if (err.confirmation) {
state.confirmation = err.confirmation;
state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond';
}
}
// Detect platform-level dialogs (About, Support Info, Error Report)
// These are NOT 1C forms — invisible to detectForms() and not closeable via Escape.
const pd = await detectPlatformDialogs();
if (pd.length) state.platformDialogs = pd;
return state;
}
@@ -0,0 +1,253 @@
// web-test nav/navigation v1.17 — Section navigation, openCommand, switchTab, navigateLink (Shift+F11), openFile.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import {
page, ensureConnected, highlightMode, resolveProjectPath,
} from '../core/state.mjs';
import {
readSectionsScript, readTabsScript, readCommandsScript,
navigateSectionScript, openCommandScript, switchTabScript,
detectFormScript,
} from '../../dom.mjs';
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
import { waitForStable, waitForCondition } from '../core/wait.mjs';
import { highlight, unhighlight } from '../recording/highlight.mjs';
import { returnFormState } from '../core/helpers.mjs';
// Static import — ESM cycle that resolves at call time.
import { pasteText } from '../core/clipboard.mjs';
import { getFormState } from '../forms/state.mjs';
/**
* Get current page state: active section, tabs.
* Combined into a single evaluate call.
*/
export async function getPageState() {
ensureConnected();
const { sections, tabs } = await page.evaluate(`({
sections: ${readSectionsScript()},
tabs: ${readTabsScript()}
})`);
const activeSection = sections.find(s => s.active)?.name || null;
const activeTab = tabs.find(t => t.active)?.name || null;
return { activeSection, activeTab, sections, tabs };
}
/** Read section panel + commands in a single evaluate call. */
export async function getSections() {
ensureConnected();
const { sections, commands } = await page.evaluate(`({
sections: ${readSectionsScript()},
commands: ${readCommandsScript()}
})`);
const activeSection = sections.find(s => s.active)?.name || null;
return { activeSection, sections, commands };
}
/** Navigate to a section by name. Returns new state with commands. */
export async function navigateSection(name) {
ensureConnected();
await dismissPendingErrors();
if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {}
const result = await page.evaluate(navigateSectionScript(name));
if (result?.error) {
const avail = result.available?.filter(Boolean);
if (avail?.length === 0) throw new Error(`navigateSection: "${name}" not found. Section panel is in icon-only mode — text labels are hidden. Switch to "Text" or "Picture and text" display mode in 1C settings (View → Section Panel → Display Mode)`);
throw new Error(`navigateSection: "${name}" not found. Available: ${avail?.join(', ') || 'none'}`);
}
await waitForStable();
const { sections, commands } = await page.evaluate(`({
sections: ${readSectionsScript()},
commands: ${readCommandsScript()}
})`);
return returnFormState({ navigated: result, sections, commands });
}
/** Read commands of the current section. */
export async function getCommands() {
ensureConnected();
return await page.evaluate(readCommandsScript());
}
/** Open a command from function panel by name. Returns new form state. */
export async function openCommand(name) {
ensureConnected();
await dismissPendingErrors();
if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {}
const formBefore = await page.evaluate(detectFormScript());
const result = await page.evaluate(openCommandScript(name));
if (result?.error) throw new Error(`openCommand: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`);
await waitForStable(formBefore);
return await returnFormState();
}
/** Switch to an open tab by name (fuzzy match). Returns updated form state. */
export async function switchTab(name) {
ensureConnected();
const result = await page.evaluate(switchTabScript(name));
if (result?.error) throw new Error(`switchTab: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`);
await waitForStable();
return returnFormState();
}
// English → Russian metadata type mapping for e1cib navigation links
const E1CIB_TYPE_MAP = {
'catalog': 'Справочник', 'catalogs': 'Справочник',
'document': 'Документ', 'documents': 'Документ',
'commonmodule': 'ОбщийМодуль',
'enum': 'Перечисление', 'enums': 'Перечисление',
'dataprocessor': 'Обработка', 'dataprocessors': 'Обработка',
'report': 'Отчет', 'reports': 'Отчет',
'accumulationregister': 'РегистрНакопления',
'informationregister': 'РегистрСведений',
'accountingregister': 'РегистрБухгалтерии',
'calculationregister': 'РегистрРасчета',
'chartofaccounts': 'ПланСчетов',
'chartofcharacteristictypes': 'ПланВидовХарактеристик',
'chartofcalculationtypes': 'ПланВидовРасчета',
'businessprocess': 'БизнесПроцесс',
'task': 'Задача',
'exchangeplan': 'ПланОбмена',
'constant': 'Константа',
};
// Types that open via e1cib/app/ (reports and data processors have their own app forms)
const E1CIB_APP_TYPES = new Set(['Отчет', 'Обработка']);
function normalizeE1cibUrl(url) {
// Already a full e1cib link
if (url.startsWith('e1cib/')) return url;
// "ТипОбъекта.Имя" or "EnglishType.Имя" — translate type, pick list/ or app/ prefix
const dot = url.indexOf('.');
if (dot > 0) {
const typePart = url.substring(0, dot);
const namePart = url.substring(dot + 1);
const ruType = E1CIB_TYPE_MAP[typePart.toLowerCase()] || typePart;
const prefix = E1CIB_APP_TYPES.has(ruType) ? 'e1cib/app' : 'e1cib/list';
return `${prefix}/${ruType}.${namePart}`;
}
return `e1cib/list/${url}`;
}
/**
* Open an external data processor or report (EPF/ERF) via File → Open menu.
* Handles the security confirmation dialog on first open.
* @param {string} filePath - path to EPF/ERF file (absolute or relative to cwd)
* @returns {Promise<object>} form state of the opened processor/report
*/
export async function openFile(filePath) {
ensureConnected();
await dismissPendingErrors();
const absPath = resolveProjectPath(filePath.replace(/\\/g, '/'));
const MAX_ATTEMPTS = 2; // 1st may trigger security dialog, 2nd is the real open
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const formBefore = await page.evaluate(detectFormScript());
// 1. Ctrl+O opens 1C's "Выбор файлов" dialog
await page.keyboard.press('Control+o');
// 2. Wait for the file selection dialog
const dialogOk = await waitForCondition(`(() => {
const ok = document.querySelector('#fileSelectDialogOk');
return ok && ok.offsetWidth > 0 ? true : false;
})()`, 3000);
if (!dialogOk) throw new Error("File selection dialog did not open (Ctrl+O)");
// 3. Click "выберите с диска" to trigger the native OS file picker
let fileChooser;
try {
[fileChooser] = await Promise.all([
page.waitForEvent('filechooser', { timeout: 5000 }),
page.click('a.underline.pointer'),
]);
} catch (e) {
// Try closing the dialog before throwing
await page.keyboard.press('Escape');
throw new Error(`File chooser did not appear: ${e.message}`);
}
// 4. Set the file path and click OK
await fileChooser.setFiles(absPath);
await page.waitForTimeout(500);
await page.click('#fileSelectDialogOk');
await waitForStable(formBefore);
// 5. Check for security dialog
const err = await checkForErrors();
if (err?.confirmation) {
// Security confirmation — click the positive button (Продолжить/Да/OK)
const positiveBtn = err.confirmation.buttons.find(b =>
/продолжить|да|ok|yes|открыть/i.test(b)
) || err.confirmation.buttons[0];
if (positiveBtn) {
const btns = await page.$$(`#form${err.confirmation.formNum}_container a.press.pressButton`);
for (const b of btns) {
const txt = (await b.textContent())?.trim();
if (txt === positiveBtn) { await b.click(); break; }
}
await waitForStable(formBefore);
}
// After confirmation, check if EPF form appeared or a follow-up dialog showed.
// Check form change FIRST — avoids confusing a small EPF form with a modal dialog.
const formAfter = await page.evaluate(detectFormScript());
if (formAfter != null && formAfter !== formBefore) {
// New form appeared — but is it the EPF or an informational dialog?
// Informational "re-open" dialogs are tiny (< 20 elements).
const elCount = await page.evaluate(`document.querySelectorAll('[id^="form${formAfter}_"]').length`);
if (elCount < 20) {
// Likely an info dialog — check and dismiss
const err2 = await checkForErrors();
if (err2?.modal) {
await dismissPendingErrors();
await waitForStable(formBefore);
continue; // retry open cycle
}
}
// It's the real EPF form
return returnFormState({ opened: { file: absPath, attempt: attempt + 1 } });
}
// Form didn't appear — retry
continue;
}
// No security dialog — check if form appeared
if (err?.modal) {
throw new Error(`Error opening file: ${err.modal.message}`);
}
const formAfter = await page.evaluate(detectFormScript());
if (formAfter != null && formAfter !== formBefore) {
const state = await getFormState();
state.opened = { file: absPath, attempt: attempt + 1 };
return state;
}
}
throw new Error(`Form did not open after ${MAX_ATTEMPTS} attempts for: ${absPath}`);
}
/** Navigate to a 1C navigation link via Shift+F11 dialog. Returns new form state. */
export async function navigateLink(url) {
ensureConnected();
await dismissPendingErrors();
const link = normalizeE1cibUrl(url);
const formBefore = await page.evaluate(detectFormScript());
// Copy link to clipboard, press Shift+F11 (opens "Go to link" dialog with clipboard content)
await pasteText(link, { confirm: 'Shift+F11', postDelay: 200 });
await waitForStable();
// Click "Перейти" in the navigation dialog
const dialog = await page.evaluate(detectFormScript());
if (dialog != null && dialog !== formBefore) {
const btns = await page.$$(`#form${dialog}_container a.press`);
for (const b of btns) {
const txt = (await b.textContent())?.trim();
if (txt === 'Перейти') { await b.click(); break; }
}
}
await waitForStable(formBefore);
return await returnFormState();
}
@@ -0,0 +1,292 @@
// web-test recording/captions v1.17 — Overlay primitives: captions, title slides, image overlays.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { existsSync as fsExistsSync, readFileSync } from 'fs';
import { extname } from 'path';
import {
page, recorder, lastCaptions, ensureConnected, resolveProjectPath,
} from '../core/state.mjs';
/**
* Show a text caption overlay on the page (visible in recording).
* Calling again updates the text without creating a new element.
* @param {string} text — caption text
* @param {object} [opts]
* @param {'top'|'bottom'} [opts.position='bottom'] — vertical position
* @param {number} [opts.fontSize=24] — font size in pixels
* @param {string} [opts.background='rgba(0,0,0,0.7)'] — background color
* @param {string} [opts.color='#fff'] — text color
* @param {string|false} [opts.speech] — TTS narration text. Omit to use displayed text,
* pass a string for custom narration, or false to skip narration for this caption.
*/
export async function showCaption(text, opts = {}) {
ensureConnected();
// Collect caption for TTS narration if recording
let smartWaitMs = 0;
if (recorder && (text.trim() || typeof opts.speech === 'string') && opts.speech !== false) {
const speech = typeof opts.speech === 'string' ? opts.speech : text;
// Use video timeline position (accounts for frame duplication) instead of wall-clock
recorder.captions.push({ text: text || speech, speech, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
// Estimate TTS duration and wait so the video has enough screen time for voiceover
smartWaitMs = Math.max(2000, speech.length * (recorder.speechRate || 70));
}
const position = opts.position || 'bottom';
const fontSize = opts.fontSize || 24;
const bg = opts.background || 'rgba(0,0,0,0.7)';
const color = opts.color || '#fff';
await page.evaluate(({ text, position, fontSize, bg, color }) => {
let el = document.getElementById('__web_test_caption');
if (!el) {
el = document.createElement('div');
el.id = '__web_test_caption';
el.style.cssText = `
position: fixed; left: 0; right: 0; z-index: 99999;
text-align: center; padding: 12px 24px;
font-family: Arial, sans-serif; pointer-events: none;
`;
document.body.appendChild(el);
}
el.style[position === 'top' ? 'top' : 'bottom'] = '20px';
el.style[position === 'top' ? 'bottom' : 'top'] = 'auto';
el.style.fontSize = fontSize + 'px';
el.style.background = bg;
el.style.color = color;
el.textContent = text;
}, { text, position, fontSize, bg, color });
// Smart TTS wait: pause for estimated speech duration so video has enough screen time.
// Split into chunks and flush frames periodically — CDP doesn't send screencast frames
// for static pages, so we must write duplicate frames to keep video timeline in sync.
if (smartWaitMs > 0) {
let remaining = smartWaitMs;
while (remaining > 0) {
const chunk = Math.min(remaining, 1000);
await page.waitForTimeout(chunk);
remaining -= chunk;
if (recorder?._flushFrames) recorder._flushFrames();
}
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
}
}
/** Remove the caption overlay from the page. */
export async function hideCaption() {
ensureConnected();
await page.evaluate(() => {
const el = document.getElementById('__web_test_caption');
if (el) el.remove();
});
}
/**
* Get captions collected during the current or last recording.
* @returns {Array<{text: string, speech: string, time: number}>}
*/
export function getCaptions() {
if (recorder) return [...recorder.captions];
return [...lastCaptions];
}
/**
* Show a full-screen title slide overlay (for video recordings).
* Repeated calls update the content. Use hideTitleSlide() to remove.
* @param {string} text Title text (\n → line break)
* @param {object} [opts]
* @param {string} [opts.subtitle] Smaller text below the title
* @param {string} [opts.background] CSS background (default: dark gradient)
* @param {string} [opts.color] Text color (default: '#fff')
* @param {number} [opts.fontSize] Title font size in px (default: 36)
*/
export async function showTitleSlide(text, opts = {}) {
ensureConnected();
const {
subtitle = '',
background = 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
color = '#fff',
fontSize = 36,
speech,
} = opts;
// Collect caption for TTS narration if recording
let smartWaitMs = 0;
if (recorder && speech && speech !== false) {
const captionText = typeof speech === 'string' ? speech : text.replace(/\n/g, ' ');
if (captionText) {
recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
}
}
await page.evaluate(({ text, subtitle, background, color, fontSize }) => {
let div = document.getElementById('__web_test_title');
if (!div) {
div = document.createElement('div');
div.id = '__web_test_title';
document.body.appendChild(div);
}
div.style.cssText = [
'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
`background:${background}`,
'display:flex', 'align-items:center', 'justify-content:center',
'z-index:999999', 'pointer-events:none',
].join(';');
// Remove other overlays to prevent flash between slides
const img = document.getElementById('__web_test_image');
if (img) img.remove();
const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\n/g, '<br>');
let html = `<div style="font-size:${fontSize}px;font-weight:600;line-height:1.4;">${esc(text)}</div>`;
if (subtitle) {
html += `<div style="font-size:${Math.round(fontSize * 0.5)}px;margin-top:16px;opacity:0.7;">${esc(subtitle)}</div>`;
}
div.innerHTML = `<div style="text-align:center;max-width:70%;color:${color};font-family:'Segoe UI',Arial,sans-serif;">${html}</div>`;
}, { text, subtitle, background, color, fontSize });
// Smart TTS wait (same pattern as showCaption/showImage)
if (smartWaitMs > 0) {
let remaining = smartWaitMs;
while (remaining > 0) {
const chunk = Math.min(remaining, 1000);
await page.waitForTimeout(chunk);
remaining -= chunk;
if (recorder?._flushFrames) recorder._flushFrames();
}
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
}
}
/** Remove the title slide overlay. */
export async function hideTitleSlide() {
ensureConnected();
await page.evaluate(() => {
const el = document.getElementById('__web_test_title');
if (el) el.remove();
});
}
/**
* Show a full-screen image overlay (e.g. presentation slide screenshot).
* Reads the image file, base64-encodes it, and renders as a fixed overlay
* on the page — captured by CDP screencast automatically.
*
* Style presets:
* - 'blur' (default) — blurred+dimmed copy as background, image centered with shadow
* - 'dark' — dark background (#2a2a2a) with shadow
* - 'light' — white background with shadow
* - 'full' — image covers entire screen, no padding/shadow
*
* Custom background overrides the preset (e.g. background: '#003366').
*
* @param {string} imagePath — path to the image file (PNG, JPG, etc.)
* @param {object} [opts]
* @param {'blur'|'dark'|'light'|'full'} [opts.style='blur'] — display style preset
* @param {string} [opts.background] — custom background color/gradient (overrides style preset)
* @param {boolean} [opts.shadow] — show drop shadow (default: true for blur/dark/light, false for full)
* @param {string|false} [opts.speech] — TTS narration text while image is shown.
* Pass a string for narration, or false to skip. Omit to skip (no auto-text for images).
*/
export async function showImage(imagePath, opts = {}) {
ensureConnected();
const style = opts.style || 'blur';
const speech = opts.speech;
// Style presets
const presets = {
blur: { bg: '#222', fit: 'contain', shadow: true, blur: true },
dark: { bg: '#2a2a2a', fit: 'contain', shadow: true, blur: false },
light: { bg: '#ffffff', fit: 'contain', shadow: true, blur: false },
full: { bg: '#000', fit: 'contain', shadow: false, blur: false },
};
const preset = presets[style] || presets.blur;
const bg = opts.background || preset.bg;
const fit = preset.fit;
const shadow = opts.shadow !== undefined ? opts.shadow : preset.shadow;
const useBlur = opts.background ? false : preset.blur;
// Read image and base64-encode
const absPath = resolveProjectPath(imagePath);
if (!fsExistsSync(absPath)) {
throw new Error(`showImage: file not found: ${absPath}`);
}
const buf = readFileSync(absPath);
const ext = extname(absPath).toLowerCase().replace('.', '');
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
: ext === 'png' ? 'image/png'
: ext === 'gif' ? 'image/gif'
: ext === 'webp' ? 'image/webp'
: ext === 'svg' ? 'image/svg+xml'
: 'image/png';
const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
// Collect caption for TTS narration if recording
let smartWaitMs = 0;
if (recorder && speech && speech !== false) {
const captionText = typeof speech === 'string' ? speech : '';
if (captionText) {
recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
}
}
// Padding: full style uses 100%, others use 92% for breathing room
const isFull = style === 'full';
const maxSize = isFull ? '100%' : '92%';
await page.evaluate(({ dataUrl, fit, bg, useBlur, shadow, maxSize, isFull }) => {
let div = document.getElementById('__web_test_image');
if (!div) {
div = document.createElement('div');
div.id = '__web_test_image';
document.body.appendChild(div);
}
// Remove other overlays to prevent flash between slides
const title = document.getElementById('__web_test_title');
if (title) title.remove();
div.style.cssText = [
'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
`background:${bg}`,
'display:flex', 'align-items:center', 'justify-content:center',
'z-index:999999', 'pointer-events:none', 'overflow:hidden'
].join(';');
let html = '';
// Blurred background layer: the same image stretched to cover, blurred and dimmed
if (useBlur) {
html += `<img src="${dataUrl}" style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;filter:blur(30px) brightness(0.5);transform:scale(1.1);" />`;
}
// Main image
const shadowCss = shadow ? 'box-shadow:0 4px 40px rgba(0,0,0,0.5);' : '';
const sizeCss = isFull
? `width:100%;height:100%;object-fit:${fit};`
: `max-width:${maxSize};max-height:${maxSize};min-width:50%;min-height:50%;object-fit:${fit};`;
html += `<img src="${dataUrl}" style="position:relative;${sizeCss}${shadowCss}" />`;
div.innerHTML = html;
}, { dataUrl, fit, bg, useBlur, shadow, maxSize, isFull });
// Smart TTS wait (same pattern as showCaption)
if (smartWaitMs > 0) {
let remaining = smartWaitMs;
while (remaining > 0) {
const chunk = Math.min(remaining, 1000);
await page.waitForTimeout(chunk);
remaining -= chunk;
if (recorder?._flushFrames) recorder._flushFrames();
}
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
}
}
/** Remove the image overlay. */
export async function hideImage() {
ensureConnected();
await page.evaluate(() => {
const el = document.getElementById('__web_test_image');
if (el) el.remove();
});
}
@@ -0,0 +1,243 @@
// web-test recording/capture v1.17 — Recording lifecycle (CDP screencast + ffmpeg pipe), screenshot, wait helpers.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { spawn } from 'child_process';
import { mkdirSync, statSync, writeFileSync } from 'fs';
import { dirname } from 'path';
import {
page, recorder, lastCaptions,
setRecorder, setLastCaptions, setLastRecordingDuration,
resolveProjectPath, ensureConnected,
} from '../core/state.mjs';
import { resolveFfmpeg } from './tts.mjs';
// Imported lazily inside wait() to avoid initialization-time circular deps.
/** Take a screenshot. Returns PNG buffer. */
export async function screenshot() {
ensureConnected();
return await page.screenshot({ type: 'png' });
}
/** Wait for a specified number of seconds. */
export async function wait(seconds) {
ensureConnected();
let ms = seconds * 1000;
// Credit system: if showCaption already waited for TTS, subtract that time
if (recorder && recorder.captionCredit) {
const elapsed = Date.now() - recorder.captionCredit.at;
const credit = Math.max(0, recorder.captionCredit.waitedMs - elapsed);
ms = Math.max(0, ms - credit);
recorder.captionCredit = null;
}
if (ms > 0) {
// During recording, split long waits into chunks and flush frames
// to keep video timeline in sync (CDP may not send frames for static pages)
if (recorder?._flushFrames && ms > 1000) {
let remaining = ms;
while (remaining > 0) {
const chunk = Math.min(remaining, 1000);
await page.waitForTimeout(chunk);
remaining -= chunk;
recorder._flushFrames();
}
} else {
await page.waitForTimeout(ms);
}
}
const { getFormState } = await import('../forms/state.mjs');
return await getFormState();
}
// ============================================================
// Video recording — CDP screencast + ffmpeg
// ============================================================
/** Check if video recording is active. */
export function isRecording() {
return recorder !== null;
}
/**
* Start video recording via CDP screencast + ffmpeg.
* Frames are captured as JPEG and piped to ffmpeg for MP4 encoding.
* @param {string} outputPath — output .mp4 file path
* @param {object} [opts]
* @param {number} [opts.fps=25] — target framerate
* @param {number} [opts.quality=80] — JPEG quality (1-100)
* @param {string} [opts.ffmpegPath] — explicit path to ffmpeg binary
*/
export async function startRecording(outputPath, opts = {}) {
ensureConnected();
if (recorder) {
if (opts.force) {
try { await stopRecording(); } catch {}
} else {
throw new Error('Already recording. Call stopRecording() first, or use { force: true }.');
}
}
setLastCaptions([]);
setLastRecordingDuration(null);
const fps = opts.fps || 25;
const quality = opts.quality || 80;
const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
// Ensure output directory exists
const resolvedPath = resolveProjectPath(outputPath);
mkdirSync(dirname(resolvedPath), { recursive: true });
// Spawn ffmpeg process — single output file across context switches
const ffmpeg = spawn(ffmpegPath, [
'-y', // overwrite output
'-f', 'image2pipe', // input: piped images
'-framerate', String(fps), // input framerate
'-i', '-', // read from stdin
'-c:v', 'libx264', // H.264 codec
'-preset', 'fast', // good quality/speed balance
'-crf', '23', // default quality (good for screen content)
'-vf', 'scale=in_range=full:out_range=limited', // JPEG full→H.264 limited range
'-pix_fmt', 'yuv420p', // broad compatibility
'-color_range', 'tv', // limited range (16-235) — standard for H.264 players
'-movflags', '+faststart', // web-friendly MP4
resolvedPath
], { stdio: ['pipe', 'ignore', 'pipe'] });
ffmpeg.on('error', err => { if (recorder) recorder.ffmpegError += err.message; });
const frameDuration = 1000 / fps;
const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
// Frame handler shared across CDP sessions (lives in recorder, not closure):
// when the active context switches, we attach a new CDP session and route its
// frames to the same ffmpeg pipe — preserving a single continuous timeline.
const frameHandler = async ({ data, sessionId }, cdp) => {
if (!recorder) return;
const buf = Buffer.from(data, 'base64');
const now = Date.now();
if (!ffmpeg.stdin.destroyed) {
let framesWritten = 0;
if (recorder.lastFrameTime && recorder.lastFrameBuf) {
const gap = now - recorder.lastFrameTime;
const dupes = Math.round(gap / frameDuration) - 1;
for (let i = 0; i < dupes && i < fps * 30; i++) {
ffmpeg.stdin.write(recorder.lastFrameBuf);
framesWritten++;
}
}
ffmpeg.stdin.write(buf);
framesWritten++;
recorder.videoTimeMs += framesWritten * frameDuration;
}
recorder.lastFrameTime = now;
recorder.lastFrameBuf = buf;
try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {}
};
// Duplicate the last frame to fill wall-clock gaps (static periods, context switches).
const _flushFrames = () => {
if (!recorder || !recorder.lastFrameBuf || !recorder.lastFrameTime || ffmpeg.stdin.destroyed) return;
const now = Date.now();
const gap = now - recorder.lastFrameTime;
const dupes = Math.round(gap / frameDuration);
for (let i = 0; i < dupes; i++) {
ffmpeg.stdin.write(recorder.lastFrameBuf);
recorder.videoTimeMs += frameDuration;
}
if (dupes > 0) recorder.lastFrameTime = now;
};
// Attach screencast to a specific page. Stops the old CDP first (if any).
// Called by startRecording for the initial page, and by setActiveContext when
// the active context changes mid-recording.
const _attachPage = async (targetPage) => {
if (recorder.cdp) {
_flushFrames(); // freeze the last frame of the outgoing page up to "now"
try { await recorder.cdp.send('Page.stopScreencast'); } catch {}
try { await recorder.cdp.detach(); } catch {}
recorder.cdp = null;
}
const cdp = await targetPage.context().newCDPSession(targetPage);
cdp.on('Page.screencastFrame', (ev) => frameHandler(ev, cdp));
await cdp.send('Page.startScreencast', { format: 'jpeg', quality, everyNthFrame: 1 });
recorder.cdp = cdp;
recorder.activePage = targetPage;
};
setRecorder({
cdp: null,
activePage: null,
ffmpeg,
startTime: Date.now(),
outputPath: resolvedPath,
ffmpegError: '',
captions: [],
videoTimeMs: 0,
frameDuration,
lastFrameTime: null,
lastFrameBuf: null,
_flushFrames,
_attachPage,
speechRate,
});
ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); });
await _attachPage(page);
}
/**
* Stop video recording. Finalizes the MP4 file.
* @returns {{ file: string, duration: number, size: number }}
*/
export async function stopRecording() {
if (!recorder) return { file: null, duration: 0, size: 0 };
const { cdp, ffmpeg, startTime, outputPath } = recorder;
// Final frame flush: write remaining frames to cover the gap since the last screencast frame
if (recorder._flushFrames) recorder._flushFrames();
// Stop CDP screencast
try { await cdp.send('Page.stopScreencast'); } catch {}
try { await cdp.detach(); } catch {}
// Close ffmpeg stdin and wait for encoding to finish
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ffmpeg.kill('SIGKILL');
reject(new Error('ffmpeg timed out after 30s'));
}, 30000);
ffmpeg.on('close', (code) => {
clearTimeout(timeout);
if (code === 0) resolve();
else reject(new Error(`ffmpeg exited with code ${code}: ${recorder?.ffmpegError || ''}`));
});
ffmpeg.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
ffmpeg.stdin.end();
});
const duration = (Date.now() - startTime) / 1000;
const stats = statSync(outputPath);
// Preserve captions for addNarration()
setLastCaptions(recorder.captions || []);
setLastRecordingDuration(duration);
if (lastCaptions.length) {
const captionsPath = outputPath.replace(/\.[^.]+$/, '.captions.json');
const captionsData = { recordingDuration: duration, videoTimestamps: true, captions: lastCaptions };
writeFileSync(captionsPath, JSON.stringify(captionsData, null, 2), 'utf-8');
}
setRecorder(null);
return {
file: outputPath,
duration: Math.round(duration * 10) / 10,
size: stats.size,
captions: lastCaptions.length
};
@@ -0,0 +1,340 @@
// web-test recording/highlight v1.17 — Visual highlight overlay (single + auto-mode for clickElement/fillFields/selectValue).
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import {
page, highlightMode, ensureConnected, normYo,
setHighlightMode,
} from '../core/state.mjs';
import {
readSubmenuScript, detectFormScript, resolveGridScript,
findClickTargetScript, resolveFieldsScript,
} from '../../dom.mjs';
/**
* Highlight an element on the page (visual accent for video recordings).
* Uses overlay div for visibility (not clipped by overflow:hidden), with
* requestAnimationFrame tracking so it follows layout shifts (async banners etc).
* @param {string} text Element text/label (fuzzy match, same as clickElement/fillFields)
* @param {object} [opts]
* @param {string} [opts.color] Outline color (default: '#e74c3c')
* @param {number} [opts.padding] Extra padding around element (default: 4)
*/
export async function highlight(text, opts = {}) {
ensureConnected();
const { color = '#e74c3c', padding = 4, table } = opts;
// Remove previous highlight first
await unhighlight();
let elId = null;
// 0. Open submenu/popup — highest priority (submenu overlays the form,
// so form search would match grid rows behind the popup)
const popupItems = await page.evaluate(readSubmenuScript());
if (Array.isArray(popupItems) && popupItems.length > 0) {
const target = normYo(text.toLowerCase());
let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).startsWith(target));
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
if (found) {
// 1C duplicates IDs in clouds — getElementById returns the hidden copy.
// Use elementFromPoint to find the visible element and get its actual rect.
await page.evaluate(({ x, y, color, padding }) => {
const el = document.elementFromPoint(x, y);
if (!el) return;
const block = el.closest('.submenuBlock') || el.closest('a.press') || el;
const r = block.getBoundingClientRect();
let div = document.getElementById('__web_test_highlight');
if (!div) {
div = document.createElement('div');
div.id = '__web_test_highlight';
document.body.appendChild(div);
}
div.style.cssText = [
'position:fixed', 'pointer-events:none', 'z-index:999998',
`top:${r.y - padding}px`, `left:${r.x - padding}px`,
`width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
`outline:3px solid ${color}`, 'border-radius:4px',
`box-shadow:0 0 16px ${color}80`,
].join(';');
}, { x: found.x, y: found.y, color, padding });
return; // overlay placed, done
}
}
// 1. Visible commands on the function panel (cmd_XXX_txt elements)
// Must be checked BEFORE form search: when the section content panel
// is showing, the form behind it is hidden but detectFormScript still
// finds it, and form buttons match before commands.
if (!elId) {
elId = await page.evaluate(`(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0);
if (cmds.length === 0) return null;
let el = cmds.find(e => norm(e.innerText).toLowerCase() === target);
if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().startsWith(target));
if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().includes(target));
return el ? el.id : null;
})()`);
}
// 1b. Command group headers on the function panel (eAccentColor labels).
// Match header text, then highlight the header + commands below it
// until the next spacer/header/end.
if (!elId) {
const groupDone = await page.evaluate(({ target, color, padding }) => {
const container = document.querySelector('#funcPanel_container');
if (!container) return false;
const norm = s => (s?.trim().replace(/\u00a0/g, ' ') || '').replace(/ё/gi, 'е').toLowerCase();
const headers = [...container.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0);
if (!headers.length) return false;
let headerEl = headers.find(h => norm(h.textContent) === target);
if (!headerEl) headerEl = headers.find(h => norm(h.textContent).startsWith(target));
if (!headerEl) headerEl = headers.find(h => norm(h.textContent).includes(target));
if (!headerEl) return false;
// Collect header + following cmd siblings until next spacer/header
const parent = headerEl.parentElement;
const children = [...parent.children];
const startIdx = children.indexOf(headerEl);
const groupEls = [headerEl];
for (let i = startIdx + 1; i < children.length; i++) {
const el = children[i];
if (el.classList.contains('eAccentColor')) break;
if (!el.id && !el.classList.contains('functionItem') && el.getBoundingClientRect().width < 10) break;
groupEls.push(el);
}
// Bounding box
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const el of groupEls) {
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
minX = Math.min(minX, r.left); minY = Math.min(minY, r.top);
maxX = Math.max(maxX, r.right); maxY = Math.max(maxY, r.bottom);
}
if (minX === Infinity) return false;
let div = document.getElementById('__web_test_highlight');
if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
div.style.cssText = [
'position:fixed', 'pointer-events:none', 'z-index:999998',
`top:${minY - padding}px`, `left:${minX - padding}px`,
`width:${maxX - minX + padding * 2}px`, `height:${maxY - minY + padding * 2}px`,
`outline:3px solid ${color}`, 'border-radius:4px',
`box-shadow:0 0 16px ${color}80`,
].join(';');
return true;
}, { target: normYo(text.toLowerCase()), color, padding });
if (groupDone) return;
}
// 2. Form groups/panels — checked BEFORE buttons/fields because group names
// often collide with command bar buttons (e.g. "БизнесПроцессы" is both a
// panel and a command bar element). Includes _container and _div elements
// but skips logicGroupContainer (Representation=None, height=0).
if (!elId) {
const formNum = await page.evaluate(detectFormScript());
if (formNum !== null) {
elId = await page.evaluate(`(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
const p = 'form' + ${formNum} + '_';
// Group containers: _container or _div, but skip logicGroupContainer (invisible groups)
const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
.filter(el => el.offsetWidth > 0 && el.offsetHeight > 0 && !el.classList.contains('logicGroupContainer'));
const items = groups.map(el => {
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
const titleEl = document.getElementById(p + idName + '#title_text')
|| document.getElementById(p + idName + '_title_text');
const label = norm(titleEl?.innerText || '').toLowerCase();
const name = norm(idName).toLowerCase();
const big = el.offsetWidth >= 100 && el.offsetHeight >= 50;
return { id: el.id, name, label, big };
});
let found = items.find(i => i.label === target);
if (!found) found = items.find(i => i.name === target);
// Fuzzy match: only large groups (min 100x50) to avoid matching command bars
if (!found) found = items.filter(i => i.big).find(i => i.label.startsWith(target) || i.name.startsWith(target));
if (!found && target.length >= 4) found = items.filter(i => i.big).find(i => i.label.includes(target) || i.name.includes(target));
return found ? found.id : null;
})()`);
}
}
// 3. Form-scoped search (buttons, links, fields, grid rows)
if (!elId) {
const formNum = await page.evaluate(detectFormScript());
if (formNum !== null) {
// 3a. Try button/link/tab/gridRow via findClickTargetScript
let gridSelector;
if (table) {
const resolved = await page.evaluate(resolveGridScript(formNum, table));
if (!resolved.error) gridSelector = resolved.gridSelector;
}
const target = await page.evaluate(findClickTargetScript(formNum, text, table ? { tableName: table, gridSelector } : undefined));
if (target && !target.error) {
if (target.id) {
elId = target.id;
} else if (target.x && target.y) {
// Grid row — find the gridLine element and tag it
elId = await page.evaluate(`(() => {
const p = ${JSON.stringify(`form${formNum}_`)};
const grid = document.querySelector('[id^="' + p + '"].grid');
if (!grid) return null;
const body = grid.querySelector('.gridBody');
if (!body) return null;
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
for (const line of body.querySelectorAll('.gridLine')) {
const cells = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
const rowText = cells.map(b => b.innerText?.trim() || '').join(' ').toLowerCase().replace(/ё/gi, 'е');
if (rowText.includes(target)) {
if (!line.id) line.id = '__wt_hl_tmp';
return line.id;
}
}
return null;
})()`);
}
}
// 3b. If not found as button — try as field via resolveFieldsScript
if (!elId) {
const dummyFields = { [text]: '' };
const resolved = await page.evaluate(resolveFieldsScript(formNum, dummyFields));
if (resolved?.length > 0 && !resolved[0].error && resolved[0].inputId) {
elId = resolved[0].inputId;
}
}
}
}
// 4. Fallback: sections (sidebar navigation)
if (!elId) {
elId = await page.evaluate(`(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
let el = secs.find(e => norm(e.innerText).toLowerCase() === target);
if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().startsWith(target));
if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().includes(target));
return el ? el.id : null;
})()`);
}
if (!elId) {
// Collect available elements to help the caller fix the name
const available = await page.evaluate(`(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const result = {};
// Commands
const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0).map(e => norm(e.innerText));
if (cmds.length) result.commands = cmds;
// Command group headers
const fp = document.querySelector('#funcPanel_container');
if (fp) {
const gh = [...fp.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0).map(e => norm(e.textContent));
if (gh.length) result.commandGroups = gh;
}
// Sections
const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')].map(e => norm(e.innerText)).filter(Boolean);
if (secs.length) result.sections = secs;
// Form elements
${(() => {
// Detect form inline to avoid extra evaluate round-trip
return `
const forms = {};
document.querySelectorAll('[id^="form"]').forEach(el => {
const m = el.id.match(/^form(\\d+)_/);
if (m) forms[m[1]] = (forms[m[1]] || 0) + 1;
});
let formNum = null, maxCount = 0;
for (const [n, c] of Object.entries(forms)) {
if (parseInt(n) > 0 && c > maxCount) { maxCount = c; formNum = n; }
}
if (formNum !== null) {
const p = 'form' + formNum + '_';
// Groups (_container or _div, skip logicGroupContainer, min 100x50)
const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
.filter(el => el.offsetWidth >= 100 && el.offsetHeight >= 50 && !el.classList.contains('logicGroupContainer'))
.map(el => {
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '_title_text');
return norm(titleEl?.innerText || '') || idName;
}).filter(Boolean);
if (groups.length) result.groups = groups;
// Buttons/links
const btns = [...document.querySelectorAll('[id^="' + p + '"].btnText, [id^="' + p + '"] .btnText, [id^="' + p + '"].hplnk')]
.filter(el => el.offsetWidth > 0).map(el => norm(el.innerText)).filter(Boolean);
if (btns.length) result.buttons = [...new Set(btns)];
}`;
})()}
return result;
})()`);
const parts = [];
for (const [cat, items] of Object.entries(available)) {
parts.push(` ${cat}: ${items.join(', ')}`);
}
const hint = parts.length ? `\nAvailable:\n${parts.join('\n')}` : '';
throw new Error(`highlight: "${text}" not found${hint}`);
}
// Overlay div + rAF tracking loop (not clipped by overflow:hidden, follows layout shifts)
await page.evaluate(({ elId, color, padding }) => {
const target = document.getElementById(elId);
if (!target) return;
let div = document.getElementById('__web_test_highlight');
if (!div) {
div = document.createElement('div');
div.id = '__web_test_highlight';
document.body.appendChild(div);
}
function sync() {
const r = target.getBoundingClientRect();
div.style.cssText = [
'position:fixed', 'pointer-events:none', 'z-index:999998',
`top:${r.y - padding}px`, `left:${r.x - padding}px`,
`width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
`outline:3px solid ${color}`, 'border-radius:4px',
`box-shadow:0 0 16px ${color}80`,
].join(';');
}
sync();
// Track position changes via rAF
function tick() {
if (!document.getElementById('__web_test_highlight')) return; // stopped
sync();
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}, { elId, color, padding });
}
/** Remove the highlight overlay. */
export async function unhighlight() {
ensureConnected();
await page.evaluate(() => {
const el = document.getElementById('__web_test_highlight');
if (el) el.remove(); // also stops rAF loop (id check)
// Clean up temp ID from grid rows
const tmp = document.getElementById('__wt_hl_tmp');
if (tmp) tmp.removeAttribute('id');
});
}
/**
* Toggle auto-highlight mode. When enabled, clickElement/fillFields/selectValue
* automatically highlight the target element before acting.
* @param {boolean} on true to enable, false to disable
*/
export function setHighlight(on) {
setHighlightMode(!!on);
}
/** @returns {boolean} Whether auto-highlight mode is active. */
export function isHighlightMode() {
return highlightMode;
}
@@ -0,0 +1,196 @@
// web-test recording/narration v1.17 — Post-process: generate TTS audio for captions and merge with recorded video.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { execFileSync } from 'child_process';
import { existsSync as fsExistsSync, mkdirSync, readFileSync, rmSync, statSync } from 'fs';
import { extname, join as pathJoin } from 'path';
import { tmpdir } from 'os';
import {
lastCaptions, lastRecordingDuration, resolveProjectPath,
} from '../core/state.mjs';
import {
resolveFfmpeg, getTtsProvider, getAudioDuration, generateSilence,
} from './tts.mjs';
/**
* Add TTS narration to a recorded video.
* Generates speech from captions and merges audio with the video.
* @param {string} videoPath — path to the recorded MP4 file
* @param {object} [opts]
* @param {Array<{text: string, speech: string, time: number, voice?: string}>} [opts.captions] — explicit captions (default: from last recording or .captions.json). Each caption may include a `voice` field to override the global voice for that segment
* @param {string} [opts.provider='edge'] — TTS provider: 'edge' or 'openai'
* @param {string} [opts.voice] — voice name (provider-specific)
* @param {string} [opts.apiKey] — API key (for openai provider)
* @param {string} [opts.apiUrl] — API endpoint (for openai provider)
* @param {string} [opts.model] — model name (for openai provider, default: 'tts-1')
* @param {string} [opts.ffmpegPath] — path to ffmpeg binary
* @param {string} [opts.outputPath] — output file path (default: video-narrated.mp4)
* @returns {{ file: string, duration: number, size: number, captions: number, warnings?: string[] }}
*/
export async function addNarration(videoPath, opts = {}) {
if (!videoPath) return { file: null, duration: 0, size: 0, captions: 0 };
videoPath = resolveProjectPath(videoPath);
const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
const ttsProvider = getTtsProvider(opts.provider || 'edge');
const ttsOpts = { voice: opts.voice, apiKey: opts.apiKey, apiUrl: opts.apiUrl, model: opts.model };
// Resolve captions: explicit > lastCaptions > .captions.json
let captions = opts.captions;
let videoTimestamps = true; // new recordings use video-time timestamps (no scaling needed)
let recordingDuration = null; // wall-clock duration (for legacy scaling fallback)
if (!captions || !captions.length) {
if (lastCaptions.length) {
captions = [...lastCaptions];
recordingDuration = lastRecordingDuration;
// Runtime captions always use video timestamps (set in showCaption)
}
}
if (!captions || !captions.length) {
const captionsJsonPath = videoPath.replace(/\.[^.]+$/, '.captions.json');
if (fsExistsSync(captionsJsonPath)) {
const raw = JSON.parse(readFileSync(captionsJsonPath, 'utf-8'));
// Support formats: array (old), { recordingDuration, captions } (v2), { videoTimestamps, captions } (v3)
if (Array.isArray(raw)) {
captions = raw;
videoTimestamps = false;
} else {
captions = raw.captions;
videoTimestamps = !!raw.videoTimestamps;
recordingDuration = raw.recordingDuration || null;
}
}
}
if (!captions || !captions.length) {
throw new Error('No captions available. Record with showCaption() first, or pass opts.captions.');
}
const videoDuration = getAudioDuration(videoPath, ffmpegPath);
// Legacy fallback: scale wall-clock timestamps to video duration
// (only for old captions without videoTimestamps flag)
if (!videoTimestamps && recordingDuration && recordingDuration > 0) {
const timeScale = videoDuration / recordingDuration;
if (Math.abs(timeScale - 1) > 0.005) {
captions = captions.map(c => ({ ...c, time: Math.round(c.time * timeScale) }));
}
}
// Output path
const ext = extname(videoPath);
const base = videoPath.slice(0, -ext.length);
const outputPath = opts.outputPath || `${base}-narrated${ext}`;
// Temp directory
const tempDir = pathJoin(tmpdir(), `web-test-tts-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
const warnings = [];
try {
// Phase 1: Generate TTS audio for each caption
const ttsFiles = [];
const BATCH_SIZE = (opts.provider === 'elevenlabs') ? 2 : 5;
for (let batchStart = 0; batchStart < captions.length; batchStart += BATCH_SIZE) {
const batch = captions.slice(batchStart, batchStart + BATCH_SIZE);
const promises = batch.map(async (cap, batchIdx) => {
const idx = batchStart + batchIdx;
const ttsFile = pathJoin(tempDir, `tts_${idx}.mp3`);
const capTtsOpts = cap.voice ? { ...ttsOpts, voice: cap.voice } : ttsOpts;
try {
await ttsProvider(cap.speech, ttsFile, capTtsOpts);
} catch (err) {
// Retry once
try {
await ttsProvider(cap.speech, ttsFile, capTtsOpts);
} catch (retryErr) {
warnings.push(`TTS failed for caption ${idx}: ${retryErr.message || retryErr.cause?.message || String(retryErr)}`);
// Generate 1s silence as placeholder
generateSilence(ttsFile, 1, ffmpegPath);
}
}
return ttsFile;
});
const results = await Promise.all(promises);
ttsFiles.push(...results);
}
// Phase 2+3: Place each TTS at its exact timestamp using adelay + amix
// This avoids MP3 frame quantization drift from silence-file concatenation
const ffmpegInputs = [];
const filterParts = [];
const mixLabels = [];
for (let i = 0; i < captions.length; i++) {
const captionTimeMs = Math.round(captions[i].time);
const ttsFile = ttsFiles[i];
const ttsDuration = getAudioDuration(ttsFile, ffmpegPath);
ffmpegInputs.push('-i', ttsFile);
const filters = [];
// Speed up TTS slightly if it's longer than gap to next caption (max 1.3x)
if (i < captions.length - 1) {
const maxDuration = (captions[i + 1].time - captions[i].time) / 1000;
if (ttsDuration > maxDuration && maxDuration > 0.1) {
const tempo = ttsDuration / maxDuration;
if (tempo <= 1.3) {
filters.push(`atempo=${tempo.toFixed(4)}`);
} else {
// Too fast — let audio overlap instead of distorting
warnings.push(`Caption ${i + 1}/${captions.length}: TTS ${ttsDuration.toFixed(1)}s > gap ${maxDuration.toFixed(1)}s (need ${Math.round(ttsDuration - maxDuration)}s more pause)`);
}
}
}
// Delay to exact caption timestamp (milliseconds)
if (captionTimeMs > 0) {
filters.push(`adelay=${captionTimeMs}|${captionTimeMs}`);
}
const label = `a${i}`;
mixLabels.push(`[${label}]`);
// Input indices are shifted by 1 because silence reference is input [0]
filterParts.push(`[${i + 1}]${filters.length ? filters.join(',') : 'acopy'}[${label}]`);
}
// Generate a silence reference track as input [0] so amix runs for full video duration
const silencePath = pathJoin(tempDir, 'silence.mp3');
generateSilence(silencePath, Math.ceil(videoDuration), ffmpegPath);
const filterComplex = filterParts.join(';') + ';' +
`[0]${mixLabels.join('')}amix=inputs=${captions.length + 1}:normalize=0:duration=first`;
const narrationPath = pathJoin(tempDir, 'narration.mp3');
execFileSync(ffmpegPath, [
'-y', '-i', silencePath, ...ffmpegInputs,
'-filter_complex', filterComplex,
'-t', String(Math.ceil(videoDuration)),
'-c:a', 'libmp3lame', '-b:a', '128k', narrationPath,
], { stdio: 'pipe', timeout: 120000 });
// Phase 4: Merge video + narration audio
execFileSync(ffmpegPath, [
'-y', '-i', videoPath, '-i', narrationPath,
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
'-map', '0:v:0', '-map', '1:a:0',
'-t', String(Math.ceil(videoDuration)),
'-movflags', '+faststart', outputPath,
], { stdio: 'pipe', timeout: 120000 });
const stats = statSync(outputPath);
const duration = getAudioDuration(outputPath, ffmpegPath);
const result = {
file: outputPath,
duration: Math.round(duration * 10) / 10,
size: stats.size,
captions: captions.length,
};
if (warnings.length) result.warnings = warnings;
return result;
} finally {
// Cleanup temp directory
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
}
}
@@ -0,0 +1,175 @@
// web-test recording/tts v1.17 — TTS providers (edge/openai/elevenlabs) and ffmpeg/ffprobe helpers.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { execFileSync, spawn } from 'child_process';
import { existsSync as fsExistsSync, writeFileSync } from 'fs';
import { resolve as pathResolve } from 'path';
import { pathToFileURL } from 'url';
import { projectRoot } from '../core/state.mjs';
/** Resolve ffmpeg binary path. */
export function resolveFfmpeg(explicit) {
// 1. Explicit path
if (explicit) {
try { execFileSync(explicit, ['-version'], { stdio: 'ignore', timeout: 5000 }); return explicit; }
catch { throw new Error(`ffmpeg not found at: ${explicit}`); }
}
// 2. FFMPEG_PATH env var
const envPath = process.env.FFMPEG_PATH;
if (envPath) {
try { execFileSync(envPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return envPath; }
catch { /* fall through */ }
}
// 3. System PATH
try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore', timeout: 5000 }); return 'ffmpeg'; }
catch { /* fall through */ }
// 4. tools/ffmpeg/bin/ffmpeg.exe relative to project root
const localPath = pathResolve(projectRoot, 'tools', 'ffmpeg', 'bin', 'ffmpeg.exe');
if (fsExistsSync(localPath)) {
try { execFileSync(localPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return localPath; }
catch { /* fall through */ }
}
// 5. Error with instructions
throw new Error(
'ffmpeg not found. Install it:\n' +
' - Download from https://www.gyan.dev/ffmpeg/builds/ (essentials build)\n' +
' - Add to PATH, or set FFMPEG_PATH env var, or place in tools/ffmpeg/bin/\n' +
' - Or pass ffmpegPath option to startRecording()'
);
}
// ── TTS providers ──────────────────────────────────────────────────────────
/** Resolve node-edge-tts module: global install → tools/tts/ → error with instructions. */
let _edgeTtsModule = null;
export async function resolveEdgeTts() {
if (_edgeTtsModule) return _edgeTtsModule;
// 1. Global/project-level install (standard Node resolution)
try {
_edgeTtsModule = await import('node-edge-tts');
return _edgeTtsModule;
} catch { /* fall through */ }
// 2. tools/tts/ relative to project root
const localPath = pathResolve(projectRoot, 'tools', 'tts', 'node_modules', 'node-edge-tts', 'dist', 'edge-tts.js');
if (fsExistsSync(localPath)) {
try {
_edgeTtsModule = await import(pathToFileURL(localPath).href);
return _edgeTtsModule;
} catch { /* fall through */ }
}
// 3. Error with instructions
throw new Error(
'node-edge-tts not found. Install it:\n' +
' - npm install --prefix tools/tts node-edge-tts\n' +
' - or: npm install node-edge-tts (global/project-level)'
);
}
/**
* Edge TTS provider (free, no API key). Uses node-edge-tts package.
* @param {string} text — text to synthesize
* @param {string} outputPath — path for the output mp3 file
* @param {object} opts — { voice }
*/
export async function edgeTtsProvider(text, outputPath, opts = {}) {
const { EdgeTTS } = await resolveEdgeTts();
const voice = opts.voice || 'ru-RU-DmitryNeural';
const tts = new EdgeTTS({ voice });
await Promise.race([
tts.ttsPromise(text, outputPath),
new Promise((_, reject) => setTimeout(() => reject(new Error('Edge TTS timeout (30s)')), 30000)),
]);
}
/**
* OpenAI-compatible TTS provider. Requires apiKey.
* @param {string} text — text to synthesize
* @param {string} outputPath — path for the output mp3 file
* @param {object} opts — { apiKey, apiUrl, voice, model }
*/
export async function openaiTtsProvider(text, outputPath, opts = {}) {
const apiUrl = opts.apiUrl || 'https://api.openai.com/v1/audio/speech';
if (!opts.apiKey) throw new Error('OpenAI TTS requires apiKey');
const resp = await fetch(apiUrl, {
method: 'POST',
headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: opts.model || 'tts-1',
input: text,
voice: opts.voice || 'alloy',
response_format: 'mp3',
}),
});
if (!resp.ok) throw new Error(`OpenAI TTS error ${resp.status}: ${await resp.text()}`);
const buf = Buffer.from(await resp.arrayBuffer());
writeFileSync(outputPath, buf);
}
/**
* ElevenLabs TTS provider. Requires apiKey.
* @param {string} text — text to synthesize
* @param {string} outputPath — path for the output mp3 file
* @param {object} opts — { apiKey, apiUrl, voice, model }
*/
export async function elevenlabsTtsProvider(text, outputPath, opts = {}) {
const voiceId = opts.voice || 'JBFqnCBsd6RMkjVDRZzb'; // George
const apiUrl = opts.apiUrl || `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;
if (!opts.apiKey) throw new Error('ElevenLabs TTS requires apiKey');
const resp = await fetch(apiUrl, {
method: 'POST',
headers: { 'xi-api-key': opts.apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
model_id: opts.model || 'eleven_multilingual_v2',
}),
});
if (!resp.ok) throw new Error(`ElevenLabs TTS error ${resp.status}: ${await resp.text()}`);
const buf = Buffer.from(await resp.arrayBuffer());
writeFileSync(outputPath, buf);
}
/** Get TTS provider function by name. */
export function getTtsProvider(name) {
switch (name) {
case 'openai': return openaiTtsProvider;
case 'elevenlabs': return elevenlabsTtsProvider;
case 'edge': default: return edgeTtsProvider;
}
}
// ── TTS audio helpers ──────────────────────────────────────────────────────
/**
* Get audio duration in seconds using ffprobe.
* @param {string} filePath — path to audio file
* @param {string} ffmpegPath — path to ffmpeg binary (ffprobe is found next to it)
* @returns {number} duration in seconds
*/
export function getAudioDuration(filePath, ffmpegPath) {
const ffprobePath = ffmpegPath.replace(/ffmpeg(\.exe)?$/i, 'ffprobe$1');
const out = execFileSync(ffprobePath, [
'-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', filePath,
], { encoding: 'utf8', timeout: 10000 }).trim();
return parseFloat(out) || 0;
}
/**
* Generate a silence mp3 file of given duration.
* @param {string} outputPath — path for the output mp3 file
* @param {number} seconds — duration in seconds
* @param {string} ffmpegPath — path to ffmpeg binary
*/
export function generateSilence(outputPath, seconds, ffmpegPath) {
execFileSync(ffmpegPath, [
'-y', '-f', 'lavfi', '-i', `anullsrc=r=24000:cl=mono`,
'-t', String(seconds), '-c:a', 'libmp3lame', '-b:a', '32k', outputPath,
], { stdio: 'pipe', timeout: 10000 });
}
@@ -0,0 +1,568 @@
// web-test spreadsheet v1.18 — readSpreadsheet + helpers for SpreadsheetDocument (отчёты, печатные формы).
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page, ensureConnected } from '../core/state.mjs';
import { detectFormScript } from '../../dom.mjs';
import { waitForStable } from '../core/wait.mjs';
import { getFormState } from '../forms/state.mjs';
import { returnFormState } from '../core/helpers.mjs';
// --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) ---
/**
* Scan spreadsheet iframes for the current form and collect all cells.
* Returns { allCells: Map<'r_c', {r,c,t}>, frameMap: Map<'r_c', frameIndex> }
* where frameIndex is the Playwright frames[] index (1-based, 0 = main).
*/
async function scanSpreadsheetCells(formNum) {
const prefix = `form${formNum ?? 0}_`;
const iframeHandles = await page.$$('iframe');
const allCells = new Map();
const frameMap = new Map(); // key 'r_c' → Playwright Frame object
for (const handle of iframeHandles) {
const ok = await handle.evaluate((f, pfx) => {
if (f.offsetWidth < 100) return false;
let el = f.parentElement;
for (let d = 0; el && d < 30; d++, el = el.parentElement) {
if (el.id && el.id.startsWith(pfx)) return true;
}
return false;
}, prefix);
if (!ok) continue;
const frame = await handle.contentFrame();
if (!frame) continue;
try {
const cells = await frame.evaluate(`(() => {
const cells = [];
document.querySelectorAll('div[x]').forEach(d => {
const span = d.querySelector('span');
const text = span?.innerText?.replace(/\\n/g, ' ')?.trim() || '';
if (!text) return;
const rowDiv = d.parentElement;
const row = rowDiv?.getAttribute('y') || rowDiv?.className?.match(/R(\\d+)/)?.[1] || null;
const col = d.getAttribute('x');
if (row != null && col != null) cells.push({ r: parseInt(row), c: parseInt(col), t: text });
});
return cells;
})()`);
for (const cell of cells) {
const key = `${cell.r}_${cell.c}`;
if (!allCells.has(key) || cell.t.length > allCells.get(key).t.length) {
allCells.set(key, cell);
frameMap.set(key, frame);
}
}
} catch { /* skip inaccessible frames */ }
}
return { allCells, frameMap };
}
/**
* Build structured mapping from raw cells: headers, column map, data/totals row indices.
* Returns { rows, sortedRows, maxCol, colNames, headerRowIdx, dataStartIdx, totalsRowIdx, rowMap }
* or null if header detection fails.
*/
function buildSpreadsheetMapping(allCells) {
const rowMap = new Map();
let maxCol = 0;
for (const cell of allCells.values()) {
if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map());
rowMap.get(cell.r).set(cell.c, cell.t);
if (cell.c > maxCol) maxCol = cell.c;
}
const sortedRows = [...rowMap.keys()].sort((a, b) => a - b);
const rows = sortedRows.map(r => {
const cm = rowMap.get(r);
const arr = [];
for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || '');
return arr;
});
// Generic numeric check: digits with optional spaces/commas, excludes codes like "68/78"
// Accepts bare integers (e.g. account codes "50", "84") — used for hasNumber / totals classification.
const isNumericVal = (c) => {
if (!c || !/\d/.test(c)) return false;
const s = c.replace(/^[-\s\u00a0]+/, '').replace(/[\s\u00a0]/g, '');
return /^\d[\d,]*$/.test(s);
};
// Data-formatted numeric value: requires a formatting signal (grouping space, decimal comma, or leading minus).
// Used as the anchor for first data row — avoids false positives on bare account codes like "50", "51".
const isDataNumericVal = (c) => {
if (!isNumericVal(c)) return false;
return /[\s\u00a0,]/.test(c) || /^-/.test(c);
};
const hasNumber = (row) => row.some(c => isNumericVal(c));
const nonEmpty = (row) => row.filter(c => c !== '').length;
// Build a rich mapping (group/super/DCS) anchored at a known detailIdx + firstDataIdx.
// Shared by Level 1 (DCS-code anchor) and Level 2 (formatted-number anchor).
const buildRichMapping = (detailIdx, firstDataIdx) => {
let groupIdx = -1;
if (detailIdx > 0 && nonEmpty(rows[detailIdx - 1]) >= 2) groupIdx = detailIdx - 1;
const detailRow = rows[detailIdx];
const groupRow = groupIdx >= 0 ? rows[groupIdx] : null;
// Detect optional third header level above group row (bounds carry-forward)
let superRow = null;
if (groupIdx > 0 && nonEmpty(rows[groupIdx - 1]) >= 2) {
superRow = rows[groupIdx - 1];
}
// Build column names (group + detail merge)
const groupFilled = new Array(maxCol + 1).fill('');
if (groupRow) {
let cur = '';
for (let c = 0; c <= maxCol; c++) {
if (groupRow[c]) {
cur = groupRow[c];
} else if (superRow && superRow[c]) {
// New top-level header starts here — stop carry-forward
cur = '';
}
groupFilled[c] = cur;
}
}
const detailCounts = {};
for (let c = 0; c <= maxCol; c++) {
const n = detailRow[c];
if (n) detailCounts[n] = (detailCounts[n] || 0) + 1;
}
// Detect DCS column codes (К1, К2, ...) — always prefix with group when present
const detailNonEmpty = detailRow.filter(c => c);
const isDcsCodeRow = detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c));
const colNames = [];
for (let c = 0; c <= maxCol; c++) {
const detail = detailRow[c];
const group = groupFilled[c];
const sup = superRow ? superRow[c] : '';
if (detail) {
// Prefer group prefix; fall back to superRow for DCS code columns without sub-group
const prefix = group && group !== detail ? group : (isDcsCodeRow && sup ? sup : '');
const needPrefix = prefix && (isDcsCodeRow || detailCounts[detail] > 1 || (groupRow && groupRow[c] === ''));
colNames.push(needPrefix ? `${prefix} / ${detail}` : detail);
} else if (group) {
colNames.push(group);
} else if (sup) {
colNames.push(sup);
} else {
colNames.push(null);
}
}
const colMap = new Map();
for (let c = 0; c < colNames.length; c++) {
if (colNames[c]) colMap.set(colNames[c], c);
}
// Classify data rows: separate data indices and totals index
const dataRowIndices = [];
let totalsRowIdx = -1;
for (let i = firstDataIdx; i < rows.length; i++) {
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
const first = rows[i][0]?.trim().toLowerCase();
if (first === 'итого' || first === 'всего') {
totalsRowIdx = i;
} else {
dataRowIndices.push(i);
}
}
const superRowIdx = superRow ? groupIdx - 1 : -1;
return {
rows, sortedRows, maxCol, colNames, colMap,
headerRowIdx: detailIdx, groupRowIdx: groupIdx, superRowIdx,
dataStartIdx: firstDataIdx, dataRowIndices, totalsRowIdx,
rowMap, hasNumber, nonEmpty,
};
};
// --- Level 1: DCS-code row anchor ---
// ФСД / СКД-отчёты всегда содержат строку "К1, К2, ..." — rock-solid structural marker.
// Якорение через неё — детерминированное, работает даже если все данные — голые целые (отчёт в "тыс.руб").
for (let i = 0; i < rows.length; i++) {
const detailNonEmpty = rows[i].filter(c => c);
if (detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c))) {
// Find first non-empty row after the К-codes row as data start
let firstDataIdx = rows.length;
for (let j = i + 1; j < rows.length; j++) {
if (nonEmpty(rows[j]) > 0) { firstDataIdx = j; break; }
}
return buildRichMapping(i, firstDataIdx);
}
}
// --- Level 2: formatted-number anchor (heuristic for reports without DCS codes) ---
let firstDataIdx = rows.length;
for (let i = 0; i < rows.length; i++) {
if (rows[i].filter(c => isDataNumericVal(c)).length >= 2) { firstDataIdx = i; break; }
}
if (firstDataIdx === rows.length) {
for (let i = 0; i < rows.length; i++) {
if (rows[i].some(c => isDataNumericVal(c))) { firstDataIdx = i; break; }
}
}
if (firstDataIdx < rows.length) {
let detailIdx = -1;
for (let i = firstDataIdx - 1; i >= 0; i--) {
if (nonEmpty(rows[i]) >= Math.min(3, maxCol + 1)) { detailIdx = i; break; }
}
if (detailIdx !== -1) return buildRichMapping(detailIdx, firstDataIdx);
}
// --- Level 3: single-row header fallback (text-only data, query console) ---
// First "wide" row (nonEmpty >= 2) = headers, rest = data. No multi-level composition.
let headerIdx = -1;
for (let i = 0; i < rows.length; i++) {
if (nonEmpty(rows[i]) >= 2) { headerIdx = i; break; }
}
// Single-column tables: accept nonEmpty >= 1
if (headerIdx === -1 && maxCol === 0) {
for (let i = 0; i < rows.length; i++) {
if (nonEmpty(rows[i]) >= 1) { headerIdx = i; break; }
}
}
if (headerIdx === -1) return null; // truly empty — top-level fallback to { rows, total }
const detailRow = rows[headerIdx];
const colNames = [];
for (let c = 0; c <= maxCol; c++) colNames.push(detailRow[c] || null);
const colMap = new Map();
for (let c = 0; c < colNames.length; c++) {
if (colNames[c]) colMap.set(colNames[c], c);
}
const dataRowIndices = [];
let totalsRowIdx = -1;
for (let i = headerIdx + 1; i < rows.length; i++) {
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
const first = rows[i][0]?.trim().toLowerCase();
if (first === 'итого' || first === 'всего') {
totalsRowIdx = i;
} else {
dataRowIndices.push(i);
}
}
return {
rows, sortedRows, maxCol, colNames, colMap,
headerRowIdx: headerIdx, groupRowIdx: -1, superRowIdx: -1,
dataStartIdx: headerIdx + 1, dataRowIndices, totalsRowIdx,
rowMap, hasNumber, nonEmpty,
};
}
/**
* Scroll SpreadsheetDocument to make a cell visible using arrow keys.
* Uses native platform scroll — keeps headers, data, and scrollbar synchronized.
*
* How it works:
* 1. Check target cell visibility via Playwright boundingBox (page-level coords).
* 2. Click a fully-visible cell via page.mouse.click through the mxlCurrBody overlay.
* This is the same native click that clickSpreadsheetCell uses — it gives keyboard
* focus to the spreadsheet and keeps headers/data/scrollbar in sync.
* (frame.locator().click() bypasses overlay → desyncs frozen headers;
* page.mouse.click() + frameEl.focus() doesn't transfer keyboard focus.)
* 3. Press ArrowRight/ArrowLeft until the target cell is fully within the viewport.
*
* @param {Frame} frame - Playwright Frame containing the spreadsheet cells
* @param {number} physRow - physical row (y attribute) in the frame
* @param {number} physCol - physical column (x attribute) in the frame
* @param {Locator} cellLoc - Playwright locator for the target cell (from caller)
*/
async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) {
const pageVw = await page.evaluate('window.innerWidth');
// Get iframe bounds — the actual visible region on page.
// The iframe may extend behind the section panel on the left, so cells with
// x >= 0 but x < iframeBox.x are behind the panel. Clicking them hits the panel.
const frameElm = await frame.frameElement();
const frameBox = await frameElm.boundingBox();
const visLeft = frameBox ? frameBox.x : 0;
const visRight = frameBox ? Math.min(frameBox.x + frameBox.width, pageVw) : pageVw;
const getBox = async () => {
try { return await cellLoc.boundingBox({ timeout: 500 }); }
catch { return null; }
};
const isFullyVisible = (box) => box && box.x >= visLeft && (box.x + box.width) <= visRight;
let box = await getBox();
if (!box) return; // cell not in DOM
if (isFullyVisible(box)) return;
const direction = (box.x + box.width) > pageVw ? 'ArrowRight' : 'ArrowLeft';
// Find a fully-visible cell to click for focus.
// Prefer cells in the target row (scrollable area), fall back to any row.
const targetRowSel = `div[y="${physRow}"] div[x]`;
const anyRowSel = 'div[x]';
let focusClicked = false;
for (const sel of [targetRowSel, anyRowSel]) {
const locs = frame.locator(sel);
const count = await locs.count();
const candidates = [];
for (let ci = 0; ci < count; ci++) {
const b = await locs.nth(ci).boundingBox();
if (b && b.width > 5 && b.x >= visLeft && (b.x + b.width) <= visRight) {
candidates.push({ ci, box: b });
}
}
if (candidates.length === 0) continue;
candidates.sort((a, b) => a.box.x - b.box.x);
// ArrowRight → rightmost fully-visible (each press scrolls right immediately)
// ArrowLeft → leftmost fully-visible (each press scrolls left immediately)
const pick = direction === 'ArrowRight'
? candidates[candidates.length - 1]
: candidates[0];
// Native click through overlay — gives keyboard focus + no header desync.
await page.mouse.click(pick.box.x + pick.box.width / 2, pick.box.y + pick.box.height / 2);
await page.waitForTimeout(100);
focusClicked = true;
break;
}
if (!focusClicked) return; // no visible cells — can't scroll
// Arrow keys until cell is fully visible or we detect no progress.
const MAX_STALE = 5; // bail out if arrows aren't scrolling (lost focus?)
let prevCx = box.x + box.width / 2;
let staleCount = 0;
for (let i = 0; i < 100; i++) {
await page.keyboard.press(direction);
await page.waitForTimeout(50);
box = await getBox();
if (!box) break;
if (isFullyVisible(box)) break;
const cx = box.x + box.width / 2;
if (Math.abs(cx - prevCx) >= 1) {
staleCount = 0;
} else {
staleCount++;
if (staleCount >= MAX_STALE) break;
}
prevCx = cx;
}
await page.waitForTimeout(200);
}
/**
* Click a cell in SpreadsheetDocument by logical coordinates.
* target: { row: number|'totals'|{colName: value}, column: string }
* Internal helper — called from clickElement when first arg is an object.
*/
export async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) {
ensureConnected();
const formNum = await page.evaluate(detectFormScript());
const { allCells, frameMap } = await scanSpreadsheetCells(formNum);
if (allCells.size === 0) throw new Error('clickElement: no SpreadsheetDocument found on current form.');
const mapping = buildSpreadsheetMapping(allCells);
if (!mapping) throw new Error('clickElement: could not detect spreadsheet headers. Use readSpreadsheet() to check report structure.');
const { rows, sortedRows, colNames, colMap, dataRowIndices, totalsRowIdx } = mapping;
// Resolve column (exact → endsWith " / X" → includes)
let colName = target.column;
if (!colMap.has(colName)) {
const available = colNames.filter(n => n);
const suffix = ' / ' + colName;
const match = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(colName));
if (!match) throw new Error(`clickElement: column "${colName}" not found. Available: ${available.join(', ')}`);
colName = match;
}
const physCol = colMap.get(colName);
// Resolve row → index into rows[] array
let rowIdx;
const row = target.row;
if (row === 'totals') {
if (totalsRowIdx === -1) throw new Error('clickElement: no totals row found in spreadsheet.');
rowIdx = totalsRowIdx;
} else if (typeof row === 'number') {
if (row < 0 || row >= dataRowIndices.length) throw new Error(`clickElement: row index ${row} out of range (0..${dataRowIndices.length - 1}).`);
rowIdx = dataRowIndices[row];
} else if (typeof row === 'object') {
// Filter: { colName: value } — find first data row where column matches
const filterEntries = Object.entries(row);
const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || '';
const resolveCol = (name) => {
if (colMap.has(name)) return colMap.get(name);
const suffix = ' / ' + name;
const available = colNames.filter(n => n);
const m = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(name));
return m ? colMap.get(m) : null;
};
rowIdx = dataRowIndices.find(i => {
return filterEntries.every(([fCol, fVal]) => {
const fColIdx = resolveCol(fCol);
if (fColIdx == null) return false;
const cellText = norm(rows[i][fColIdx]);
const search = norm(fVal);
return cellText === search || cellText.includes(search);
});
});
if (rowIdx == null) throw new Error(`clickElement: no row matching ${JSON.stringify(row)} found in spreadsheet data.`);
} else {
throw new Error('clickElement: row must be a number, "totals", or { colName: value } filter object.');
}
// Map rows[] index → physical row number
const physRow = sortedRows[rowIdx];
const cellKey = `${physRow}_${physCol}`;
const frame = frameMap.get(cellKey);
if (!frame) {
// Cell exists in mapping but might be empty — try clicking anyway
throw new Error(`clickElement: cell at row=${JSON.stringify(target.row)}, column="${colName}" is empty or not rendered.`);
}
// Use [y]+[x] attributes — CSS class RxCy uses different numbering than y/x attrs.
const cellDiv = frame.locator(`div[y="${physRow}"] div[x="${physCol}"]`).first();
// Scroll cell into view using arrow keys — the only reliable way to scroll
// 1C SpreadsheetDocument without desynchronizing headers, data, and scrollbar.
await scrollSpreadsheetToCell(frame, physRow, physCol, cellDiv);
const box = await cellDiv.boundingBox();
if (!box) throw new Error(`clickElement: cell y=${physRow} x=${physCol} not visible (no bounding box).`);
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
if (modKey) await page.keyboard.down(modKey);
if (dbl) {
await page.mouse.dblclick(x, y);
} else {
await page.mouse.click(x, y);
}
if (modKey) await page.keyboard.up(modKey);
await waitForStable();
return returnFormState({ clicked: { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) } });
}
/**
* Search spreadsheet iframes for a cell matching text (for text fallback in clickElement).
* Returns { frameIndex, physRow, physCol, box } or null if not found.
*/
export async function findSpreadsheetCellByText(formNum, searchText) {
const { allCells, frameMap } = await scanSpreadsheetCells(formNum);
if (allCells.size === 0) return null;
const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || '';
const target = norm(searchText);
// Exact match first, then includes
let found = null;
for (const [key, cell] of allCells) {
if (norm(cell.t) === target) { found = { key, cell }; break; }
}
if (!found) {
for (const [key, cell] of allCells) {
if (norm(cell.t).includes(target)) { found = { key, cell }; break; }
}
}
if (!found) return null;
const frame = frameMap.get(found.key);
if (!frame) return null;
// Scroll cell into view using native arrow-key mechanism
const cellDiv = frame.locator(`div[y="${found.cell.r}"] div[x="${found.cell.c}"]`).first();
await scrollSpreadsheetToCell(frame, found.cell.r, found.cell.c, cellDiv);
const box = await cellDiv.boundingBox();
if (!box) return null;
return { frame, physRow: found.cell.r, physCol: found.cell.c, text: found.cell.t, box };
}
/**
* Read report output (SpreadsheetDocumentField) rendered in iframes.
* 1C renders spreadsheet documents as absolutely-positioned div cells inside iframes.
* Each cell is a div[x] inside a row div[y], text content in <span>.
*
* Returns structured data:
* { title, headers, data: [{col: val}], totals: {col: val}, total }
* If header detection fails, falls back to { rows: string[][], total }.
*/
export async function readSpreadsheet() {
ensureConnected();
const formNum = await page.evaluate(detectFormScript());
const { allCells } = await scanSpreadsheetCells(formNum);
if (allCells.size === 0) {
// Check for state window messages (info bar) that explain why the report is empty
const err = await checkForErrors();
const hint = err?.stateText?.length ? err.stateText.join('; ') : '';
throw new Error('readSpreadsheet: no SpreadsheetDocument found.' + (hint ? ' State: ' + hint : ' Report may not be generated yet.'));
}
const mapping = buildSpreadsheetMapping(allCells);
if (!mapping) {
// Fallback: return raw rows
const rowMap = new Map();
let maxCol = 0;
for (const cell of allCells.values()) {
if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map());
rowMap.get(cell.r).set(cell.c, cell.t);
if (cell.c > maxCol) maxCol = cell.c;
}
const sortedRows = [...rowMap.keys()].sort((a, b) => a - b);
const rows = sortedRows.map(r => {
const cm = rowMap.get(r);
const arr = [];
for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || '');
return arr;
});
return { rows, total: rows.length };
}
const { rows, colNames, dataStartIdx, maxCol, groupRowIdx, headerRowIdx, superRowIdx, hasNumber, nonEmpty } = mapping;
// Convert data rows to objects
const data = [];
let totals = null;
const toObj = (row) => {
const obj = {};
for (let c = 0; c < colNames.length; c++) {
if (colNames[c] && row[c]) obj[colNames[c]] = row[c];
}
return obj;
};
for (let i = dataStartIdx; i < rows.length; i++) {
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
const first = rows[i][0]?.trim().toLowerCase();
if (first === 'итого' || first === 'всего') {
totals = toObj(rows[i]);
} else {
data.push(toObj(rows[i]));
}
}
// Meta: title, params, filters from rows before header (superRow is part of header, not meta)
const metaEnd = superRowIdx >= 0 ? superRowIdx : (groupRowIdx >= 0 ? groupRowIdx : headerRowIdx);
let title = '';
const meta = [];
for (let i = 0; i < metaEnd; i++) {
const parts = rows[i].filter(c => c);
if (!parts.length) continue;
if (!title) { title = parts.join(' '); continue; }
meta.push(parts.join(' '));
}
return {
title: title || undefined,
meta: meta.length ? meta : undefined,
headers: colNames.filter(n => n),
data,
totals: totals || undefined,
total: data.length,
};
}
@@ -0,0 +1,95 @@
// web-test table/click-row v1.0 — click handlers for grid row targets: gridGroup, gridTreeNode, gridRow.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// All handlers are called by core/click.mjs dispatcher after target is found.
// Each takes (target, ctx) where ctx = { formNum, modifier, dblclick, toggle, expand, ... }
// and returns a form state with `clicked: { kind, name, ... }`.
import { waitForStable } from '../core/wait.mjs';
import { modifierClick, returnFormState } from '../core/helpers.mjs';
import { getGridToggleIcon, shouldClickToggle } from './grid-toggle.mjs';
/**
* Click handler for gridGroup / gridParent targets (hierarchy mode).
* With `expand`/`toggle` — click the level-indicator icon to expand/collapse the group.
* Without — dblclick the row to enter the group / go up to parent.
*/
export async function clickGridGroupTarget(target, ctx) {
const { formNum, modifier, toggle, expand } = ctx;
if (expand != null || toggle) {
// Expand/collapse group — click the triangle icon (.gridListH/.gridListV).
// expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click.
const levelIconInfo = await getGridToggleIcon(target, formNum, {
iconSelector: '.gridListH, .gridListV',
isExpandedExpr: "icon.classList.contains('gridListV')",
});
const shouldClick = shouldClickToggle(levelIconInfo, expand, toggle);
if (shouldClick) {
if (levelIconInfo) {
await modifierClick(levelIconInfo.x, levelIconInfo.y, modifier);
} else {
// Fallback: dblclick (standard hierarchy navigation)
await modifierClick(target.x, target.y, modifier, { dbl: true });
}
}
await waitForStable(formNum);
return returnFormState({
clicked: { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) },
hint: shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.',
});
}
// Default: dblclick to enter group / go up to parent
await modifierClick(target.x, target.y, modifier, { dbl: true });
await waitForStable(formNum);
return returnFormState({ clicked: { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) } });
}
/**
* Click handler for gridTreeNode targets (tree-style grid).
* With `expand`/`toggle` — click the tree icon to expand/collapse.
* Without — single-click to select the row (no expand).
*/
export async function clickGridTreeNodeTarget(target, ctx) {
const { formNum, modifier, toggle, expand } = ctx;
if (expand != null || toggle) {
// Expand/collapse tree node — click the tree icon [tree="true"].
const treeIconInfo = await getGridToggleIcon(target, formNum, {
iconSelector: '.gridBoxImg [tree="true"]',
isExpandedExpr: '(icon.style.backgroundImage || "").includes("gx=0")',
});
const shouldClick = shouldClickToggle(treeIconInfo, expand, toggle);
if (shouldClick) {
if (treeIconInfo) {
await modifierClick(treeIconInfo.x, treeIconInfo.y, modifier);
} else {
// Fallback: dblclick on row (works for trees without clickable +/- icons)
await modifierClick(target.x, target.y, modifier, { dbl: true });
}
}
await waitForStable(formNum);
return returnFormState({
clicked: { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) },
hint: shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.',
});
}
// Default: select row (click text, no expand/collapse)
await modifierClick(target.x, target.y, modifier);
await waitForStable(formNum);
return returnFormState({
clicked: { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) },
hint: 'Row selected. Use { expand: true } to expand/collapse.',
});
}
/**
* Click handler for gridRow targets (flat list row).
* Single click selects the row; `dblclick: true` opens the item.
*/
export async function clickGridRowTarget(target, ctx) {
const { modifier, dblclick } = ctx;
await modifierClick(target.x, target.y, modifier, { dbl: !!dblclick });
await waitForStable();
return returnFormState({
clicked: { kind: 'gridRow', name: target.name, ...(dblclick ? { dblclick: true } : {}), ...(modifier ? { modifier } : {}) },
});
}
@@ -0,0 +1,248 @@
// web-test table/filter v1.19 — filterList / unfilterList — simple search + advanced-column filter badges.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page, ensureConnected, normYo, highlightMode, ACTION_WAIT } from '../core/state.mjs';
import {
detectFormScript, readSubmenuScript,
findSearchInputScript, findNamedButtonScript, findCompareTypeRadioScript, isFormVisibleScript,
findFirstGridCellCoordsScript, findColumnFirstCellCoordsScript,
readFieldSelectorInfoScript, pickFieldInSelectorDropdownScript,
readFilterDialogInfoScript, findFilterBadgeCloseScript, findFirstFilterBadgeCloseScript,
} from '../../dom.mjs';
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
import { waitForStable, waitForCondition } from '../core/wait.mjs';
import { highlight, unhighlight } from '../recording/highlight.mjs';
import { safeClick, returnFormState } from '../core/helpers.mjs';
import { selectValue, fillReferenceField } from '../forms/select-value.mjs';
import { pasteText } from '../core/clipboard.mjs';
import { getFormState } from '../forms/state.mjs';
import { clickElement } from '../core/click.mjs';
/**
* Filter the current list by field value, or search via search bar.
*
* Without field: simple search via the search bar (filters by all columns, no badge).
* With field: advanced search — clicks target column cell to auto-populate FieldSelector,
* opens dialog (Alt+F), fills Pattern, clicks Найти. Creates a real filter badge.
* Handles text, reference (with Tab autocomplete), and date fields automatically.
* Multiple filters can be chained by calling filterList multiple times.
*
* @param {string} text - Search text or date (e.g. "Мишка", "КП00", "10.03.2016")
* @param {object} [opts]
* @param {string} [opts.field] - Column name for advanced search (e.g. "Наименование", "Получатель", "Дата")
* @param {boolean} [opts.exact] - Exact match (text fields only; dates/numbers/refs always exact)
*/
export async function filterList(text, { field, exact } = {}) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('filterList: no form found');
if (!field) {
// --- Simple search: fill search input + Enter ---
const searchInfo = await page.evaluate(findSearchInputScript(formNum));
if (searchInfo) {
await page.click(`[id="${searchInfo.id}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await pasteText(text);
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await waitForStable(formNum);
return returnFormState({ filtered: { type: 'search', text } });
}
// No search input — Ctrl+F opens advanced search on such forms.
// Click first grid cell then fall through to advanced search path below.
const firstCell = await page.evaluate(findFirstGridCellCoordsScript(formNum));
if (!firstCell) throw new Error('filterList: no search input and no grid found on this form');
await page.mouse.click(firstCell.x, firstCell.y);
await page.waitForTimeout(300);
field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected)
}
// --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти ---
// Clicking a cell in the target column makes it active, so when Alt+F opens the
// advanced search dialog, FieldSelector is auto-populated with the correct field name.
// This avoids changing FieldSelector programmatically (which can cause errors).
const isDateValue = /^\d{2}\.\d{2}\.\d{4}$/.test(text.trim());
// 1. Click a cell in the target column to activate it (auto-populates FieldSelector).
// If the column isn't visible in the grid, click any cell and use DLB fallback later.
let needDlb = false;
const gridEl = await page.evaluate(findColumnFirstCellCoordsScript(formNum, field));
if (gridEl.error) throw new Error(`filterList: ${gridEl.error}`);
needDlb = !!gridEl.needDlb;
await page.mouse.click(gridEl.x, gridEl.y);
await page.waitForTimeout(500);
// 2. Open advanced search dialog via Alt+F (with fallback to Еще menu)
await page.keyboard.press('Alt+f');
await page.waitForTimeout(2000);
let dialogForm = await page.evaluate(detectFormScript());
if (dialogForm === formNum) {
// Alt+F didn't open dialog — fallback to Еще → Расширенный поиск
await clickElement('Еще');
await page.waitForTimeout(500);
const menu = await page.evaluate(readSubmenuScript());
const searchItem = Array.isArray(menu) && menu.find(i =>
i.name.replace(/ /g, ' ').toLowerCase().includes('расширенный поиск'));
if (!searchItem) {
await page.keyboard.press('Escape');
throw new Error('filterList: advanced search dialog could not be opened');
}
await page.mouse.click(searchItem.x, searchItem.y);
await page.waitForTimeout(2000);
dialogForm = await page.evaluate(detectFormScript());
if (dialogForm === formNum) {
throw new Error('filterList: advanced search dialog did not open');
}
}
// 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown
// Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field)
if (needDlb && field) {
const fsInfo = await page.evaluate(readFieldSelectorInfoScript(dialogForm));
if (normYo(fsInfo.current.toLowerCase()) !== normYo(field.toLowerCase())) {
await page.mouse.click(fsInfo.dlbX, fsInfo.dlbY);
await page.waitForTimeout(1500);
const ddResult = await page.evaluate(pickFieldInSelectorDropdownScript(field));
if (ddResult.error) {
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
throw new Error(`filterList: field "${field}" not found in FieldSelector. Available: ${ddResult.available?.join(', ') || 'none'}`);
}
await page.mouse.click(ddResult.x, ddResult.y);
await page.waitForTimeout(3000);
}
}
// 3. Read dialog state and fill Pattern
// Detect field type by Pattern's sibling buttons:
// - iCalendB → date field (Home+Shift+End+Ctrl+V to replace date value)
// - iDLB on Pattern → reference field (paste + Tab for autocomplete)
// - neither → plain text field (just paste)
const dialogInfo = await page.evaluate(readFilterDialogInfoScript(dialogForm));
if (dialogInfo.isDate) {
// Date field: fill via Home → Shift+End (select all) → Ctrl+V (paste)
if (isDateValue && dialogInfo.patternValue !== text.trim()) {
await page.click(`[id="${dialogInfo.patternId}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Home');
await page.waitForTimeout(100);
await page.keyboard.press('Shift+End');
await page.waitForTimeout(100);
await pasteText(text);
await page.waitForTimeout(500);
}
} else {
// Text or reference field: fill Pattern via clipboard paste
await page.click(`[id="${dialogInfo.patternId}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await pasteText(text);
await page.waitForTimeout(300);
if (dialogInfo.isRef) {
// Reference field: Tab triggers autocomplete to resolve text → reference value
await page.keyboard.press('Tab');
await page.waitForTimeout(2000);
}
}
// 3b. Switch CompareType if exact match requested (text fields only).
// Date/number: always exact, CompareType disabled. Reference: default exact (selects ref).
if (exact && !dialogInfo.isDate && !dialogInfo.isRef) {
const exactRadio = await page.evaluate(findCompareTypeRadioScript(dialogForm, 2));
if (exactRadio && !exactRadio.already) {
await page.mouse.click(exactRadio.x, exactRadio.y);
await page.waitForTimeout(300);
}
}
// 4. Click "Найти" via mouse.click (dialog is modal — page.click may be blocked)
const findBtnCoords = await page.evaluate(findNamedButtonScript('Найти'));
if (findBtnCoords) {
await page.mouse.click(findBtnCoords.x, findBtnCoords.y);
} else {
await clickElement('Найти');
}
await page.waitForTimeout(2000);
// 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти).
// Check the specific dialog form — not generic modalSurface — to avoid closing parent modals
// (e.g. a selection form that opened this advanced search).
for (let attempt = 0; attempt < 3; attempt++) {
const dialogVisible = await page.evaluate(isFormVisibleScript(dialogForm));
if (!dialogVisible) break;
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
}
await waitForStable(formNum);
return returnFormState({ filtered: { type: 'advanced', field, text, exact: !!exact } });
}
/**
* Remove active filters/search from the current list.
*
* Without field: clears ALL filters (Ctrl+Q for advanced search + clear search field).
* With field: clicks the × button on the specific filter badge (selective removal).
*
* @param {object} [opts]
* @param {string} [opts.field] - Remove only the filter for this field (clicks badge ×)
*/
export async function unfilterList({ field } = {}) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('unfilterList: no form found');
if (field) {
// --- Selective: click × on specific filter badge ---
const closeBtn = await page.evaluate(findFilterBadgeCloseScript(formNum, field));
if (closeBtn?.error) throw new Error(`unfilterList: filter badge "${field}" not found. Available: ${closeBtn.available?.join(', ') || 'none'}`);
await page.mouse.click(closeBtn.x, closeBtn.y);
await waitForStable(formNum);
return returnFormState({ unfiltered: { field: closeBtn.field } });
}
// --- Clear ALL filters ---
// 1. Remove all advanced filter badges (.trainItem × buttons)
for (let attempt = 0; attempt < 20; attempt++) {
const badge = await page.evaluate(findFirstFilterBadgeCloseScript(formNum));
if (!badge) break;
await page.mouse.click(badge.x, badge.y);
await waitForStable(formNum);
}
// 2. Cancel active search via Ctrl+Q
await page.keyboard.press('Control+q');
await waitForStable(formNum);
// 3. Clear simple search field if it has a value
const searchInfo = await page.evaluate(findSearchInputScript(formNum));
if (searchInfo?.value) {
await page.click(`[id="${searchInfo.id}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await page.keyboard.press('Delete');
await page.keyboard.press('Enter');
await waitForStable(formNum);
}
return returnFormState({ unfiltered: true });
}
@@ -0,0 +1,64 @@
// web-test table/grid-toggle v1.17 — shared icon-detection for grid expand/
// collapse toggles. Used by clickElement's gridGroup/gridParent and
// gridTreeNode branches; the actual mouse click stays in the caller because
// it depends on the caller-local modifier-key handling.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page } from '../core/state.mjs';
/**
* Locate the toggle icon for the grid row at `target.y`. Inspects the row
* under that Y-coordinate inside the resolved grid, returns the icon's
* center coordinates and current expanded state — or `null` if no toggle
* icon is present (e.g. leaf node or detached row).
*
* @param {{y:number, gridId?:string}} target
* @param {number} formNum
* @param {object} opts
* @param {string} opts.iconSelector — CSS selector inside .gridLine
* (e.g. '.gridListH, .gridListV' for groups, '.gridBoxImg [tree="true"]' for tree nodes)
* @param {string} opts.isExpandedExpr — JS expression evaluated in browser
* context where `icon` is the matched element; must yield a boolean
* (e.g. "icon.classList.contains('gridListV')" or "(icon.style.backgroundImage || '').includes('gx=0')")
* @returns {Promise<{x:number, y:number, isExpanded:boolean}|null>}
*/
export async function getGridToggleIcon(target, formNum, { iconSelector, isExpandedExpr }) {
return await page.evaluate(`(() => {
const p = ${JSON.stringify(`form${formNum}_`)};
const gridSel = ${JSON.stringify(target.gridId ? '#' + target.gridId : null)};
const grid = gridSel ? document.querySelector(gridSel) : document.querySelector('[id^="' + p + '"].grid');
const body = grid?.querySelector('.gridBody');
if (!body) return null;
const targetY = ${target.y};
const lines = [...body.querySelectorAll('.gridLine')];
for (const line of lines) {
const lr = line.getBoundingClientRect();
if (targetY < lr.top || targetY > lr.bottom) continue;
const icon = line.querySelector(${JSON.stringify(iconSelector)});
if (icon) {
const r = icon.getBoundingClientRect();
const isExpanded = ${isExpandedExpr};
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isExpanded };
}
}
return null;
})()`);
}
/**
* Standard expand/toggle decision: should we click the toggle icon?
* - `toggle:true` → always click.
* - `expand:true` → click only if not already expanded.
* - `expand:false` → click only if currently expanded.
* - If no icon found (`iconInfo` is null) → click anyway (caller falls back to dblclick).
*
* @param {{isExpanded:boolean}|null} iconInfo
* @param {boolean|undefined} expand
* @param {boolean|undefined} toggle
* @returns {boolean}
*/
export function shouldClickToggle(iconInfo, expand, toggle) {
return toggle || !iconInfo
|| (expand === true && !iconInfo.isExpanded)
|| (expand === false && iconInfo.isExpanded);
}
@@ -0,0 +1,78 @@
// web-test table/grid v1.19 — Form-grid operations: read table rows, fill rows, delete rows.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// "Grid" в терминах 1С — таблица на форме (.gridLine/.gridBody/.grid в DOM):
// табличные части документов, формы списков, ТЧ настроек и т.п.
// Отдельно от SpreadsheetDocument (engine/spreadsheet/spreadsheet.mjs).
import { page, ensureConnected } from '../core/state.mjs';
import { detectFormScript, readTableScript, resolveGridScript } from '../../dom.mjs';
import { findDeleteRowCoordsScript, countGridRowsScript } from '../../dom/grid.mjs';
import { dismissPendingErrors } from '../core/errors.mjs';
import { waitForStable } from '../core/wait.mjs';
import { clickElement } from '../core/click.mjs';
import { returnFormState } from '../core/helpers.mjs';
/** Read structured table data with pagination. Returns columns, rows, total count. */
export async function readTable({ maxRows = 20, offset = 0, table } = {}) {
ensureConnected();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('readTable: no form found');
let gridSelector;
if (table) {
const resolved = await page.evaluate(resolveGridScript(formNum, table));
if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
gridSelector = resolved.gridSelector;
}
return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector }));
}
/**
* Delete a row from the current table part.
* Single click to select the row, then Delete key to remove it.
*
* @param {number} row - 0-based row index to delete
* @param {Object} [options]
* @param {string} [options.tab] - Switch to this form tab before operating
* @returns {object} form state with { deleted, rowsBefore, rowsAfter }
*/
export async function deleteTableRow(row, { tab, table } = {}) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('deleteTableRow: no form found');
// Pre-resolve grid when table is specified
let gridSelector;
if (table) {
const resolved = await page.evaluate(resolveGridScript(formNum, table));
if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
gridSelector = resolved.gridSelector;
}
// 1. Switch tab if requested
if (tab) {
await clickElement(tab);
await page.waitForTimeout(500);
}
// 2. Find the target row and click to select it
const cellCoords = await page.evaluate(findDeleteRowCoordsScript(gridSelector, row));
if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);
const rowsBefore = cellCoords.total;
// Single click to select the row
await page.mouse.click(cellCoords.x, cellCoords.y);
await page.waitForTimeout(300);
// 3. Press Delete to remove the row
await page.keyboard.press('Delete');
await waitForStable();
// 4. Count rows after deletion
const rowsAfter = await page.evaluate(countGridRowsScript(gridSelector));
return returnFormState({ deleted: row, rowsBefore, rowsAfter });
}
@@ -0,0 +1,784 @@
// web-test table/row-fill v1.19 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import {
page, ensureConnected, normYo, highlightMode, ACTION_WAIT,
} from '../core/state.mjs';
import {
detectFormScript, resolveGridScript, readTableScript,
countGridRowsScript, isTreeGridScript, findGridHeadCenterCoordsScript,
getSelectedOrLastRowIndexScript,
isNotInListCloudVisibleScript, clickShowAllInNotInListCloudScript,
sortFieldKeysByColindexScript, findCellCoordsByFieldsScript,
findNextCellCoordsByKeyScript, findCheckboxAtPointScript,
findRowCommitClickCoordsScript, getGridEditCheckScript,
readActiveGridCellScript, getElementCenterCoordsByIdScript,
} from '../../dom.mjs';
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
import { waitForStable, waitForCondition, startNetworkMonitor } from '../core/wait.mjs';
import { highlight, unhighlight } from '../recording/highlight.mjs';
import {
safeClick, findFieldInputId, returnFormState,
detectNewForm as helperDetectNewForm,
isInputFocused, isInputFocusedInGrid, findOpenPopup,
readEdd, isEddVisible, clickEddItemViaDispatch,
} from '../core/helpers.mjs';
import { clickElement } from '../core/click.mjs';
import {
pickFromSelectionForm, isTypeDialog, pickFromTypeDialog,
fillReferenceField, selectValue,
} from '../forms/select-value.mjs';
import { pasteText } from '../core/clipboard.mjs';
/**
* Fill cells in the current table row via Tab navigation.
* Grid cells are only accessible sequentially (Tab) — no random access.
*
* After "Добавить", 1C enters inline edit mode on the first cell.
* All inputs in the row are created hidden (offsetWidth=0); only the active one is visible.
* Tab moves through cells in a fixed order determined by the form configuration.
*
* @param {Object} fields - { fieldName: value } map (fuzzy match: "Номенклатура" → "ТоварыНоменклатура")
* @param {Object} [options]
* @param {string} [options.tab] - Switch to this form tab before operating
* @param {boolean} [options.add] - Click "Добавить" to create a new row first
* @returns {{ filled[], notFilled[]?, form }}
*/
export async function fillTableRow(fields, { tab, add, row, table } = {}) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('fillTableRow: no form found');
// Pre-resolve grid when table is specified
let gridSelector;
if (table) {
const resolved = await page.evaluate(resolveGridScript(formNum, table));
if (resolved.error) throw new Error(`fillTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
gridSelector = resolved.gridSelector;
}
try {
// 1. Switch tab if requested
if (tab) {
await clickElement(tab);
}
// 2. Add new row if requested
let addedRowIdx = -1;
if (add) {
// Count rows before add — new row will be appended at this index
addedRowIdx = await page.evaluate(countGridRowsScript(gridSelector));
await clickElement('Добавить', { table });
// Poll for edit mode (INPUT inside grid) instead of fixed 1000ms wait
for (let aw = 0; aw < 6; aw++) {
await page.waitForTimeout(150);
if (await isInputFocusedInGrid()) break;
}
}
// 2b. Enter edit mode on existing row by dblclick
if (row != null) {
// Sort fields by colindex (leftmost first) so Tab traversal covers all fields left-to-right
const sortedKeys = await page.evaluate(
sortFieldKeysByColindexScript(gridSelector, Object.keys(fields).map(k => k.toLowerCase())));
if (sortedKeys) {
// Rebuild fields in sorted order
const sortedFields = {};
for (const kl of sortedKeys) {
const origKey = Object.keys(fields).find(k => k.toLowerCase() === kl);
if (origKey) sortedFields[origKey] = fields[origKey];
}
// Add any keys not matched in header (preserve original order for those)
for (const k of Object.keys(fields)) {
if (!(k in sortedFields)) sortedFields[k] = fields[k];
}
fields = sortedFields;
}
const cellCoords = await page.evaluate(
findCellCoordsByFieldsScript(gridSelector, row, Object.keys(fields).map(k => k.toLowerCase())));
if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);
// Skip if cell already contains the desired value (single-field optimization)
const firstKey0 = Object.keys(fields)[0];
const rawFirstVal = fields[firstKey0];
const firstVal0 = rawFirstVal === null || rawFirstVal === undefined || rawFirstVal === ''
? '' : (typeof rawFirstVal === 'object' ? rawFirstVal.value : String(rawFirstVal));
let firstFieldSkipped = false;
if (cellCoords.currentText && firstVal0 &&
cellCoords.currentText.toLowerCase().includes(firstVal0.toLowerCase())) {
firstFieldSkipped = true;
if (Object.keys(fields).length === 1) {
return returnFormState({ filled: [{ field: firstKey0, ok: true, method: 'skip', value: cellCoords.currentText }] });
}
}
// Click first (tree grids enter edit on single click; dblclick toggles expand/collapse).
// Then escalate: dblclick → F4 if needed.
await page.mouse.click(cellCoords.x, cellCoords.y);
// Clear cell via Shift+F4 if value is empty
if (firstVal0 === '') {
await page.waitForTimeout(500);
// Check if click opened a selection form — close it first
let openedForm = await helperDetectNewForm(formNum);
if (openedForm !== null) {
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
} else {
// No form opened — need to enter edit mode first (dblclick), then close any form that opens
await page.mouse.dblclick(cellCoords.x, cellCoords.y);
await page.waitForTimeout(500);
openedForm = await helperDetectNewForm(formNum);
if (openedForm !== null) {
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
}
}
await page.keyboard.press('Shift+F4');
await page.waitForTimeout(300);
const results = [{ field: firstKey0, ok: true, method: 'clear', value: '' }];
// If more fields remain, process them on the same row
const remaining = { ...fields };
delete remaining[firstKey0];
if (Object.keys(remaining).length > 0) {
const more = await fillTableRow(remaining, { row, table });
results.push(...more.filled);
}
return returnFormState({ filled: results });
}
// Check if clicked cell is a checkbox (toggle-on-click, no edit mode)
const checkboxInfo = await page.evaluate(findCheckboxAtPointScript(cellCoords.x, cellCoords.y));
if (checkboxInfo !== null) {
// Checkbox cell found — click directly on the checkbox icon (not cell center)
const desired = ['true', 'да', '1', 'yes'].includes(String(firstVal0).toLowerCase().trim());
if (checkboxInfo.checked !== desired) {
await page.mouse.click(checkboxInfo.x, checkboxInfo.y);
await page.waitForTimeout(300);
}
const results = [{ field: firstKey0, ok: true, method: 'toggle', value: desired }];
await waitForStable(formNum);
// If more fields remain, process them on the same row
const remaining = { ...fields };
delete remaining[firstKey0];
if (Object.keys(remaining).length > 0) {
const more = await fillTableRow(remaining, { row, table });
results.push(...more.filled);
}
return returnFormState({ filled: results });
}
let inEdit = false;
let directEditForm = null;
for (let dw = 0; dw < 4; dw++) {
await page.waitForTimeout(150);
inEdit = await isInputFocused();
if (inEdit) break;
directEditForm = await helperDetectNewForm(formNum);
if (directEditForm !== null) break;
}
// Click didn't enter edit — try dblclick (works for flat grids)
if (!inEdit && directEditForm === null) {
await page.mouse.dblclick(cellCoords.x, cellCoords.y);
for (let dw = 0; dw < 4; dw++) {
await page.waitForTimeout(150);
inEdit = await isInputFocused();
if (inEdit) break;
directEditForm = await helperDetectNewForm(formNum);
if (directEditForm !== null) break;
}
}
// Still nothing — try F4 (opens selection for direct-edit cells)
if (!inEdit && directEditForm === null) {
await page.keyboard.press('F4');
for (let fw = 0; fw < 8; fw++) {
await page.waitForTimeout(200);
inEdit = await isInputFocused();
if (inEdit) break;
directEditForm = await helperDetectNewForm(formNum);
if (directEditForm !== null) break;
}
}
// When click entered INPUT mode but no selection form yet — try F4 only for tree grids
// (tree grid ref fields need F4 to open selection form; flat grids work via Tab-loop)
if (inEdit && directEditForm === null) {
const isTreeGrid = await page.evaluate(isTreeGridScript(gridSelector));
if (isTreeGrid) {
await page.keyboard.press('F4');
for (let fw = 0; fw < 8; fw++) {
await page.waitForTimeout(200);
directEditForm = await helperDetectNewForm(formNum);
if (directEditForm !== null) break;
}
// If F4 didn't open a selection form, fall through to Tab loop
}
}
// Direct-edit mode: selection form opened on dblclick/F4 (e.g. tree grid with immediate editing).
// Handle each field by picking from selection form, then dblclick next cell.
if (directEditForm !== null) {
const pending = new Map();
for (const [key, val] of Object.entries(fields)) {
if (val && typeof val === 'object' && 'value' in val) {
pending.set(key, { value: String(val.value), type: val.type || null, filled: false });
} else {
pending.set(key, { value: String(val), type: null, filled: false });
}
}
const results = [];
// Helper: handle type dialog + pick from selection form
async function directEditPick(openedForm, key, info) {
let selForm = openedForm;
// Check if opened form is a type selection dialog (composite type field)
if (await isTypeDialog(selForm)) {
if (info.type) {
await pickFromTypeDialog(selForm, info.type);
await waitForStable(selForm);
// After type selection, detect the actual selection form
selForm = await helperDetectNewForm(formNum);
if (selForm === null) {
return { field: key, ok: false, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` };
}
} else {
// No type specified — close type dialog and report error
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
return { field: key, ok: false, error: 'composite_type', message: `Composite type field "${key}" requires {value, type}` };
}
}
const pr = await pickFromSelectionForm(selForm, key, info.value, formNum);
return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, ok: false, error: pr.error, message: pr.message };
}
// First field: selection form is already open from the dblclick above
const firstKey = Object.keys(fields)[0];
const firstInfo = pending.get(firstKey);
if (firstFieldSkipped) {
firstInfo.filled = true;
results.push({ field: firstKey, ok: true, method: 'skip', value: cellCoords.currentText });
// Close the selection form that opened from the click
await page.keyboard.press('Escape');
await waitForStable(formNum);
} else {
const pickResult = await directEditPick(directEditForm, firstKey, firstInfo);
firstInfo.filled = true;
results.push(pickResult);
}
// Remaining fields: dblclick on each column cell individually
for (const [key, info] of pending) {
if (info.filled) continue;
// Find column for this key and dblclick on it
const nextCoords = await page.evaluate(findNextCellCoordsByKeyScript(gridSelector, row, key));
if (!nextCoords) {
info.filled = true;
results.push({ field: key, ok: false, error: 'column_not_found', message: `Column for "${key}" not found` });
continue;
}
// Skip if cell already contains the desired value
if (nextCoords.currentText && info.value &&
nextCoords.currentText.toLowerCase().includes(info.value.toLowerCase())) {
info.filled = true;
results.push({ field: key, ok: true, method: 'skip', value: nextCoords.currentText });
continue;
}
await page.mouse.dblclick(nextCoords.x, nextCoords.y);
await page.waitForTimeout(300);
// Check if dblclick entered INPUT mode (plain text/numeric field) — before F4 which may open calculator
const inInputAfterDblclick = await isInputFocusedInGrid();
// Also check if a selection form already appeared
let selForm = await helperDetectNewForm(formNum);
if (selForm === null && inInputAfterDblclick) {
// Plain text/numeric field — fill via clipboard paste
await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] });
await page.waitForTimeout(400);
// Dismiss EDD autocomplete if it appeared
if (await isEddVisible()) {
await page.keyboard.press('Escape');
await page.waitForTimeout(200);
}
info.filled = true;
results.push({ field: key, ok: true, method: 'paste' });
continue;
}
// Poll for selection form (with F4 fallback if dblclick didn't open it)
if (selForm === null) {
for (let attempt = 0; attempt < 2 && selForm === null; attempt++) {
if (attempt === 1) await page.keyboard.press('F4'); // F4 fallback
for (let sw = 0; sw < 6; sw++) {
await page.waitForTimeout(200);
selForm = await helperDetectNewForm(formNum);
if (selForm !== null) break;
}
}
}
if (selForm === null) {
info.filled = true;
results.push({ field: key, ok: false, error: 'no_selection_form', message: `Dblclick on "${key}" did not open selection form` });
continue;
}
const pr = await directEditPick(selForm, key, info);
info.filled = true;
results.push(pr);
}
// Commit the edit: click on a different row (Escape cancels in tree grids).
// Find the first visible row that is NOT the edited row and click it.
const commitCoords = await page.evaluate(findRowCommitClickCoordsScript(gridSelector, row));
if (commitCoords) {
await page.mouse.click(commitCoords.x, commitCoords.y);
} else {
await page.keyboard.press('Escape');
}
await waitForStable(formNum);
return returnFormState({ filled: results });
}
if (!inEdit) throw new Error(`fillTableRow: click on row ${row} did not enter edit mode`);
} else {
// No row specified — verify we're in grid edit mode (active INPUT inside a .grid or .gridContent)
const editCheck = await page.evaluate(getGridEditCheckScript());
if (!editCheck.inEdit) {
throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.');
}
}
// 4. Prepare pending fields for fuzzy matching
const pending = new Map();
for (const [key, val] of Object.entries(fields)) {
if (val === null || val === undefined || val === '') {
pending.set(key, { value: '', type: null, filled: false });
} else if (val && typeof val === 'object' && 'value' in val) {
const innerVal = val.value;
pending.set(key, {
value: innerVal === null || innerVal === undefined || innerVal === '' ? '' : String(innerVal),
type: val.type || null, filled: false
});
} else {
pending.set(key, { value: String(val), type: null, filled: false });
}
}
const results = [];
const MAX_ITER = 40;
let prevCellId = null;
let nonInputCount = 0;
let firstCellId = null;
for (let iter = 0; iter < MAX_ITER; iter++) {
// Read focused element (INPUT or TEXTAREA inside grid = editable cell)
const cell = await page.evaluate(readActiveGridCellScript());
if (cell.tag !== 'INPUT' || !cell.fullName) {
// Not in an editable grid cell — Tab past (ERP has DIV focus between cells)
nonInputCount++;
// If only checkbox fields remain unfilled, stop Tab'ing to avoid creating extra rows
const onlyCheckboxLeft = [...pending.values()].every(p => p.filled ||
['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(p.value.toLowerCase().trim()));
if (nonInputCount > 3 || onlyCheckboxLeft) break;
await page.keyboard.press('Tab');
await page.waitForTimeout(300);
continue;
}
nonInputCount = 0;
// Track first cell to detect wrap-around (Tab looped back to row start)
if (firstCellId === null) firstCellId = cell.id;
else if (cell.id === firstCellId) break; // wrapped around — all cells visited
// Stuck detection: same cell twice in a row → force Tab
if (cell.id === prevCellId) {
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
prevCellId = null;
continue;
}
prevCellId = cell.id;
// Fuzzy match cell name to user field: exact → suffix → includes → no-space includes
const cellLower = cell.fullName.toLowerCase();
let matchedKey = null;
for (const [key, info] of pending) {
if (info.filled) continue;
const kl = key.toLowerCase();
if (cellLower === kl || cellLower.endsWith(kl) || cellLower.includes(kl)) {
matchedKey = key;
break;
}
// CamelCase cell names have no spaces/dashes — try matching without spaces and dashes
const klNoSpace = kl.replace(/[\s\-]+/g, '');
if (klNoSpace && (cellLower.endsWith(klNoSpace) || cellLower.includes(klNoSpace))) {
matchedKey = key;
break;
}
}
// Fallback: match by column header text (handles metadata typos in cell id)
if (!matchedKey && cell.headerText) {
const htLower = cell.headerText.toLowerCase();
for (const [key, info] of pending) {
if (info.filled) continue;
const kl = key.toLowerCase();
if (htLower === kl || htLower.endsWith(kl) || htLower.includes(kl)) {
matchedKey = key;
break;
}
}
}
if (!matchedKey) {
// Skip this cell
await page.keyboard.press('Tab');
await page.waitForTimeout(300);
continue;
}
const info = pending.get(matchedKey);
const text = info.value;
// Clear cell if value is empty (Shift+F4 = native 1C clear)
if (text === '') {
await page.keyboard.press('Shift+F4');
await page.waitForTimeout(300);
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'clear', value: '' });
if ([...pending.values()].every(p => p.filled)) break;
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
continue;
}
// If user specified a type, always clear and use type selection flow
if (info.type) {
await page.keyboard.press('Shift+F4'); // Clear cell to reset any inherited type
await page.waitForTimeout(300);
await page.keyboard.press('F4');
// Poll for type dialog form to appear
let typeForm = null;
for (let tw = 0; tw < 6; tw++) {
await page.waitForTimeout(200);
typeForm = await helperDetectNewForm(formNum);
if (typeForm !== null) break;
}
if (typeForm !== null && await isTypeDialog(typeForm)) {
await pickFromTypeDialog(typeForm, info.type);
await waitForStable(typeForm);
// After type selection, check if a selection form opened (ref types)
const selForm = await helperDetectNewForm(formNum);
if (selForm === null) {
// Primitive type — poll for calculator/calendar popup or settle on INPUT
let hasPopup = null;
for (let pw = 0; pw < 5; pw++) {
await page.waitForTimeout(200);
hasPopup = await findOpenPopup();
if (hasPopup) break;
}
if (hasPopup) {
await page.keyboard.press('Escape');
// Poll for popup to disappear
for (let dw = 0; dw < 4; dw++) {
await page.waitForTimeout(150);
if (!(await findOpenPopup())) break;
}
}
// Ensure we are in an editable INPUT for this cell
const inInput = await isInputFocused({ allowTextarea: true });
if (!inInput) {
const cellRect = await page.evaluate(getElementCenterCoordsByIdScript(cell.id));
if (cellRect) {
await page.mouse.dblclick(cellRect.x, cellRect.y);
// Poll for INPUT focus
for (let fw = 0; fw < 4; fw++) {
await page.waitForTimeout(150);
if (await isInputFocused({ allowTextarea: true })) break;
}
}
}
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
await page.waitForTimeout(400);
await page.keyboard.press('Tab');
await page.waitForTimeout(300);
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type });
continue;
}
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
info.filled = true;
results.push(pickResult.ok
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type }
: { field: matchedKey, cell: cell.fullName, ok: false,
error: pickResult.error, message: pickResult.message });
continue;
}
// F4 opened something but not a type dialog — close and report
if (typeForm !== null) {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
}
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'type_dialog_failed',
message: `Cell "${matchedKey}": F4 did not open type dialog for type "${info.type}"` });
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
continue;
}
// === Fill this cell: clipboard paste (trusted event) ===
await page.keyboard.press('Control+A');
await pasteText(text);
await page.waitForTimeout(1500);
// Check if paste was rejected (composite-type cell blocks text input until type is selected)
const inputAfterPaste = await page.evaluate(`document.activeElement?.value || ''`);
if (!inputAfterPaste && text) {
// No type specified — can't fill this composite-type cell
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'type_required',
message: `Cell "${matchedKey}" rejected text input (composite-type). Use { value: '...', type: 'Тип' } syntax` });
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
continue;
}
// Check for EDD autocomplete (indicates reference field)
const edd = await readEdd();
const eddItems = edd.visible ? edd.items.map(i => i.name) : null;
if (eddItems && eddItems.length > 0) {
// Reference field with autocomplete — click best match
// Filter out reference field "create" actions (Создать элемент, Создать группу, Создать: ...)
// but keep standalone enum values like "Создать" (no space/colon after)
const realItems = eddItems.filter(i => !/^Создать[\s:]/.test(i));
if (realItems.length > 0) {
const tgt = normYo(text.toLowerCase());
let pick = realItems.find(i =>
normYo(i.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === tgt);
if (!pick) pick = realItems.find(i => normYo(i.toLowerCase()).includes(tgt));
if (!pick) pick = realItems[0];
// Click EDD item via dispatchEvent (bypasses div.surface overlay)
await clickEddItemViaDispatch(pick);
await waitForStable();
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: true,
method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') });
} else {
// Only "Создать:" items — value not found in autocomplete
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'not_found', message: `No match for "${text}"` });
}
// Done? If so, don't Tab (avoids creating a new row after last cell)
if ([...pending.values()].every(p => p.filled)) break;
// Tab to move to next cell
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
continue;
}
// No EDD — press Tab to commit the value
await page.keyboard.press('Tab');
await page.waitForTimeout(1000);
// Check for "нет в списке" cloud popup (reference field, value not found)
const notInList = await page.evaluate(isNotInListCloudVisibleScript());
if (notInList) {
// Cloud has "Показать все" link — try to open selection form via it
const clickedShowAll = await page.evaluate(clickShowAllInNotInListCloudScript());
if (clickedShowAll) {
await waitForStable(formNum);
// Check if selection form opened
const selForm = await helperDetectNewForm(formNum, { strict: true });
if (selForm !== null) {
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
info.filled = true;
if (pickResult.ok) {
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'form' });
continue;
}
// Not found in selection form — fall through to clear + skip
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: pickResult.error, message: pickResult.message });
} else {
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'not_found', message: `Value "${text}" not in list` });
}
} else {
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'not_found', message: `Value "${text}" not in list` });
}
// 1C won't let us Tab away from an invalid ref value.
// Must clear the field first, then Tab to move on.
// Escape dismisses the cloud; Ctrl+A + Delete clears the text.
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await page.keyboard.press('Control+A');
await page.keyboard.press('Delete');
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
continue;
}
// Check for a new form (broad detection — also catches type dialogs whose buttons lack IDs)
const newForm = await helperDetectNewForm(formNum);
if (newForm !== null) {
if (await isTypeDialog(newForm)) {
// Composite-type cell — need type to proceed
if (info.type) {
await pickFromTypeDialog(newForm, info.type);
await waitForStable(newForm);
// After type selection, the actual selection form should open
const selForm = await helperDetectNewForm(formNum);
if (selForm === null) {
// Primitive type — poll for calculator/calendar popup or settle on INPUT
let hasPopup = null;
for (let pw = 0; pw < 5; pw++) {
await page.waitForTimeout(200);
hasPopup = await findOpenPopup();
if (hasPopup) break;
}
if (hasPopup) {
await page.keyboard.press('Escape');
for (let dw = 0; dw < 4; dw++) {
await page.waitForTimeout(150);
if (!(await findOpenPopup())) break;
}
}
const inInput = await isInputFocused({ allowTextarea: true });
if (!inInput) {
const cellRect = await page.evaluate(getElementCenterCoordsByIdScript(cell.id));
if (cellRect) {
await page.mouse.dblclick(cellRect.x, cellRect.y);
for (let fw = 0; fw < 4; fw++) {
await page.waitForTimeout(150);
if (await isInputFocused({ allowTextarea: true })) break;
}
}
}
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
await page.waitForTimeout(400);
await page.keyboard.press('Tab');
await page.waitForTimeout(300);
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type });
continue;
}
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
info.filled = true;
results.push(pickResult.ok
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type }
: { field: matchedKey, cell: cell.fullName, ok: false,
error: pickResult.error, message: pickResult.message });
continue;
} else {
// No type specified — close dialog, clear cell, report error
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await page.keyboard.press('Control+A');
await page.keyboard.press('Delete');
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'type_required',
message: `Cell "${matchedKey}" opened a type selection dialog. Use { value: '...', type: 'Тип' } syntax` });
continue;
}
}
// Not a type dialog — normal selection form
const pickResult = await pickFromSelectionForm(newForm, matchedKey, text, formNum);
info.filled = true;
results.push(pickResult.ok
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' }
: { field: matchedKey, cell: cell.fullName, ok: false,
error: pickResult.error, message: pickResult.message });
continue;
}
// Plain field — value committed via Tab
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'direct' });
// All done?
if ([...pending.values()].every(p => p.filled)) break;
// Tab already pressed — we're on next cell
}
// Commit the new row: click on the grid header to exit edit mode.
// Clicking a different data row would re-enter edit mode on that row.
// Without this commit click, the row stays in "uncommitted add" state
// and a subsequent Escape (e.g. from closeForm) would cancel the entire row.
const commitTarget = await page.evaluate(findGridHeadCenterCoordsScript(gridSelector));
if (commitTarget) {
await page.mouse.click(commitTarget.x, commitTarget.y);
await page.waitForTimeout(500);
} else {
// Fallback: Tab out of the last cell to commit the row
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
}
// Dismiss any leftover error modals
const err = await checkForErrors();
if (err?.modal) {
try {
const btn = await page.$('a.press.pressDefault');
if (btn) { await btn.click(); await page.waitForTimeout(500); }
} catch { /* OK */ }
}
const notFilled = [...pending].filter(([_, info]) => !info.filled).map(([key]) => key);
// Retry unfilled checkbox fields via direct click (Tab skips checkbox cells)
if (notFilled.length > 0) {
const checkboxFields = {};
for (const key of notFilled) {
const val = String(pending.get(key).value).toLowerCase().trim();
if (['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(val)) {
checkboxFields[key] = pending.get(key).value;
}
}
if (Object.keys(checkboxFields).length > 0) {
// Use row index: addedRowIdx (from add mode) or fallback to selected row
const currentRow = addedRowIdx >= 0 ? addedRowIdx : (row != null ? row : await page.evaluate(getSelectedOrLastRowIndexScript(gridSelector))
);
if (currentRow >= 0) {
const more = await fillTableRow(checkboxFields, { row: currentRow, table });
results.push(...more.filled);
for (const key of Object.keys(checkboxFields)) {
const idx = notFilled.indexOf(key);
if (idx >= 0) notFilled.splice(idx, 1);
}
}
}
}
const extras = { filled: results };
if (notFilled.length > 0) extras.notFilled = notFilled;
return returnFormState(extras);
} catch (e) {
if (e.message.startsWith('fillTableRow:')) throw e;
throw new Error(`fillTableRow: ${e.message}`);
}
}
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -218,7 +218,7 @@ console.log('Расшифровка:', JSON.stringify(drilldown.rows));
| Функция | Описание | Возвращает |
|---------|----------|------------|
| `navigateSection(name)` | Перейти в раздел (fuzzy match) | `{ sections, commands }` |
| `navigateSection(name)` | Перейти в раздел (fuzzy match) | form state с `navigated`, `sections`, `commands` |
| `openCommand(name)` | Открыть команду из панели функций | form state |
| `navigateLink(path)` | Открыть по пути метаданных (`Документ.ЗаказКлиента`) | form state |
| `openFile(path)` | Открыть внешнюю обработку/отчёт (EPF/ERF) через «Файл → Открыть» | form state |
@@ -286,12 +286,14 @@ await clickElement('150 000', { dblclick: true }); // найдёт ячейку
### Действия
Все action-функции возвращают **плоский form state** (как `getFormState()`) с action-specific extras (`clicked`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`). Errors всегда на верхнем уровне `.errors` — exec-wrapper автоматически throw'ает на soft validation errors (`modal`/`balloon`).
| Функция | Описание | Возвращает |
|---------|----------|------------|
| `clickElement(text, {dblclick?, modifier?})` | Клик по кнопке/ссылке/строке. `{dblclick: true}` для открытия, `{modifier: 'ctrl'\|'shift'}` для мультиселекции. Первый аргумент может быть `{row, column}` для клика по ячейке SpreadsheetDocument (см. выше) | form state или `{ submenu }` |
| `fillFields({name: value})` | Заполнить поля (текст, чекбокс, радио, ссылки, DCS-фильтры). Пустое значение (`''`/`null`) = очистка | `{ filled: [{field, ok, method}], form }` |
| `fillFields({name: value})` | Заполнить поля (текст, чекбокс, радио, ссылки, DCS-фильтры). Пустое значение (`''`/`null`) = очистка | form state с `filled` |
| `selectValue(field, search, opts?)` | Выбрать из справочника. search: текст, `{поле: значение}` или `''`/`null` для очистки. `{ type }` для составного типа | form state с `selected` |
| `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку. Значение: строка, `{ value, type }` для составного типа, `''`/`null` для очистки | form state |
| `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку. Значение: строка, `{ value, type }` для составного типа, `''`/`null` для очистки | form state с `filled` (per-field ошибки как items `ok: false`, см. ниже) + `notFilled?` |
| `deleteTableRow(row, {tab?})` | Удалить строку по индексу | form state |
| `closeForm({save?})` | Закрыть форму. `save: false` = "Нет", `save: true` = "Да". Возвращает `closed: true/false` | form state с `closed` |
| `filterList(text, {field?, exact?})` | Фильтр списка. Без field = все колонки, с field = расширенный поиск | form state |
@@ -347,13 +349,16 @@ await page.keyboard.press('F8'); // пример: создать новый э
## Типичные ошибки
Все функции бросают исключение при ошибке (не возвращают `{ error }`). Сценарий прерывается на проблемном шаге с информативным сообщением. В интерактиве — `try/catch` для обработки.
Большинство функций бросают исключение при ошибке. Сценарий прерывается на проблемном шаге с информативным сообщением. В интерактиве — `try/catch` для обработки.
**Исключение — `fillTableRow`**: на per-field ошибках не throws, а возвращает их в `filled[]` как items с `ok: false` (`{ field, ok: false, error: 'code', message: '...' }`). Это позволяет частичное восстановление: например при `error: 'composite_type'` модель может retry'нуть конкретную ячейку с `{ value, type }` синтаксисом, не перезаполняя всю строку. Проверка — `r.filled.filter(f => !f.ok)`. Жёсткие ошибки (нет формы, table не найдена) и soft validation errors от 1С (balloon/modal) — всё равно throws.
| Проблема | Решение |
|----------|---------|
| `no form found` — форма не открыта | Добавьте `await wait(2)` после навигации |
| `not found. Available: ...` — элемент не найден | Проверьте имя через `getFormState()`, используйте вариант из Available |
| `fillFields: N of M field(s) failed` | Текст ошибки содержит список проблемных полей и доступные варианты |
| `fillTableRow` вернул item с `ok: false` | См. поле `error``composite_type` → retry с `{value, type}`; `column_not_found` → проверьте имя поля через `readTable`; `not_found` → уточните значение поиска |
| Пустой `readSpreadsheet()` | Увеличьте `await wait(N)` перед чтением |
## Особенности
+10
View File
@@ -44,10 +44,20 @@ export default async function({ navigateSection, openCommand, clickElement, sele
// Контрагент: 'DontUse' → typeahead-dropdown подавлен → selectValue идёт в form
// Менеджер: 'Auto' (дефолт) → typeahead активен → selectValue остаётся в dropdown
// Шаг подтверждает, что флаг управляет path внутри selectValue.
//
// history наполняется per-value при выборе. Делаем warm-up через form, чтобы
// second pick шёл из истории — иначе isolation-прогон зависит от того,
// выбирали ли 'ООО Юг' в предыдущих тестах (06-document и т.д.).
await navigateSection('Склад');
await openCommand('Приходная накладная');
await clickElement('Создать');
// Warm-up: первый выбор может пойти через form (если history пустая).
// Не делаем assertions — только наполняем историю.
await selectValue('Менеджер', 'ООО Юг');
await selectValue('Менеджер', ''); // clear, оставляем форму открытой
// Второй выбор того же значения — должен взяться из history через typeahead.
const r = await selectValue('Менеджер', 'ООО Юг');
log(`Менеджер (Auto): method=${r.selected?.method}`);
assert.equal(r.selected?.method, 'dropdown',
+2 -2
View File
@@ -53,7 +53,7 @@ export default async function({ navigateSection, openCommand, clickElement, fill
{ 'Согласовано': true },
{ table: 'Товары', row: 1 }
);
log(`checkbox result: ${JSON.stringify(r.filled || r)}`);
log(`checkbox result: ${JSON.stringify(r.filled)}`);
const t = await readTable({ table: 'Товары' });
log(`row 1 Согласовано='${t.rows[1]['Согласовано']}'`);
assert.equal(t.rows[1]['Согласовано'], 'true', 'Согласовано должно стать true');
@@ -65,7 +65,7 @@ export default async function({ navigateSection, openCommand, clickElement, fill
{ 'Номенклатура': '' },
{ table: 'Товары', row: 0 }
);
log(`clear result: ${JSON.stringify(r.filled || r)}`);
log(`clear result: ${JSON.stringify(r.filled)}`);
const t = await readTable({ table: 'Товары' });
log(`row 0 Номенклатура after clear='${t.rows[0]['Номенклатура']}'`);
assert.equal(t.rows[0]['Номенклатура'], '', 'Номенклатура должна быть очищена (Shift+F4)');
+50
View File
@@ -0,0 +1,50 @@
export const name = 'multi-select: clickElement с modifier ctrl/shift на gridRow';
export const tags = ['table', 'multi-select'];
export const timeout = 60000;
// Покрытие feature `modifier: 'ctrl' | 'shift'` у clickElement для grid-row.
// Ctrl+click добавляет строку в выделение; Shift+click выделяет диапазон от
// anchor'a (последнего non-modifier клика). readTable отмечает выделенные
// строки полем _selected: true.
//
// Свежая синтетика содержит ровно 4 Контрагентов (ООО Север, ООО Юг, ООО
// Восток, ООО Запад). Используем их для предсказуемых проверок.
export default async function({ navigateSection, openCommand, clickElement, readTable, closeForm, assert, step, log }) {
await step('setup: открыть список Контрагентов', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
const t = await readTable();
log(`Контрагентов в списке: ${t.total}`);
assert.ok(t.total >= 3, `Нужно как минимум 3 строки для multi-select теста, есть ${t.total}`);
});
await step('ctrl-add: click ООО Север + ctrl-click ООО Юг → 2 выделенные строки', async () => {
await clickElement('ООО Север');
await clickElement('ООО Юг', { modifier: 'ctrl' });
const t = await readTable();
const selected = t.rows.filter(r => r._selected).map(r => r['Наименование']);
log(`selected after ctrl-add: ${JSON.stringify(selected)}`);
assert.equal(selected.length, 2, '2 выделенные строки после ctrl-add');
assert.includes(selected, 'ООО Север', 'ООО Север выделен');
assert.includes(selected, 'ООО Юг', 'ООО Юг выделен');
});
await step('shift-range: shift-click на третью строку → диапазон выделен', async () => {
// Сбрасываем выделение одиночным кликом, anchor = ООО Север
await clickElement('ООО Север');
// Shift+click на ООО Восток (третий по списку) — должен выделить Север..Восток
await clickElement('ООО Восток', { modifier: 'shift' });
const t = await readTable();
const selected = t.rows.filter(r => r._selected).map(r => r['Наименование']);
log(`selected after shift-range: ${JSON.stringify(selected)}`);
assert.ok(selected.length >= 2, `Диапазон должен включать минимум 2 строки, выделено ${selected.length}`);
assert.includes(selected, 'ООО Север', 'anchor ООО Север в диапазоне');
assert.includes(selected, 'ООО Восток', 'shift-target ООО Восток в диапазоне');
});
await step('cleanup: закрыть форму', async () => {
await closeForm();
});
}
+101
View File
@@ -0,0 +1,101 @@
# Регресс-тесты web-test
E2E-тесты движка `web-test` (Playwright + изолированная синтетическая БД 1С), запускаются через `node .claude/skills/web-test/scripts/run.mjs test`.
## Запуск
```bash
# Полный регресс (все 20 тестов)
node .claude/skills/web-test/scripts/run.mjs test tests/web-test/
# Один файл
node .claude/skills/web-test/scripts/run.mjs test tests/web-test/02-crud.test.mjs
# Несколько по фильтру тегов
node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ --tags=table,smoke
# По regex имени теста
node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ --grep=multi
```
URL не указываем — берётся из `webtest.config.mjs` (`contexts.a.url` = `http://localhost:9191/webtest-runner/ru_RU`).
Exit code: 0 = все прошли, 1 = есть падения.
## CLI флаги runner'а
| Флаг | Описание |
|---|---|
| `--tags=A,B` | Запустить только тесты с одним из тегов |
| `--grep=regex` | Фильтр по имени теста |
| `--bail` | Остановиться на первой ошибке |
| `--retry=N` | Перепрогон упавших тестов N раз |
| `--timeout=ms` | Таймаут одного теста (default 30000) |
| `--report=path` | Сохранить отчёт в файл |
| `--format=json\|allure\|junit` | Формат отчёта |
| `--report-dir=path` | Корень для Allure/JUnit артефактов |
| `--screenshot=on-failure\|every-step\|off` | Когда снимать скриншоты |
| `--record` | Включить запись MP4 (CDP screencast → ffmpeg) |
## Опции стенда (после `--`)
`_hooks.mjs` поднимает изолированный стенд (Apache на `:9191`, своя БД, отдельный набор EPF). По умолчанию работает в smart-режиме: пересборка только когда поменялся `config-hash` / `epf-hash`. Принудительно — через флаги после `--`:
```bash
# Принудительно пересобрать XML + БД + EPF
node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ -- --rebuild-stand
# Точечно — только пересобрать БД из существующего XML (свежая синтетика)
node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ -- --reload-data
# Только пересобрать XML (когда хочется новой конфигурации)
node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ -- --rebuild-config
# Только EPF (внешние обработки для openFile)
node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ -- --rebuild-epf
```
| Флаг | Что делает |
|---|---|
| `--rebuild-stand` | Эквивалент всех трёх ниже |
| `--rebuild-config` | XML-исходники + БД |
| `--reload-data` | Только БД (drop+create+load+update) |
| `--rebuild-epf` | Только EPF-обработки |
## Когда пересобирать стенд
**Warm-старт (~200 ms):** lockfile + probe Apache, БД жива, EPF на диске — ничего не делаем.
**Триггеры авто-пересборки** (без флагов):
- Изменился `config-hash` синтетической XML — пересобирается конфигурация + БД.
- Изменился `epf-hash` исходников EPF — пересобираются EPF.
**Когда нужен `--rebuild-stand` вручную:**
- БД накопила «мусорных» данных от write-сценариев. `15-multi-context-handover` создаёт нового Контрагента каждый прогон с unique-именем — со временем `02-crud` начнёт падать (Контрагент `ООО Север` уезжает за `maxRows=20`).
- Подозрение что Apache держит зависший процесс — `--rebuild-stand` делает `web-stop` + `web-publish`.
## Конфигурация
`tests/web-test/webtest.config.mjs` задаёт:
- **`contexts.a` / `contexts.b`** — два независимых 1C-сеанса (разные cookies) на той же URL. Тесты с `multi-context` тегом используют оба.
- **`defaultContext: 'a'`** — большинство тестов работают в одном контексте.
- **`isolation: 'tab'`** — вкладки в одном окне (default). Альтернатива `'window'` — отдельный BrowserContext (полная изоляция cookies).
## Env переменные
| Переменная | Значение |
|---|---|
| `WEB_TEST_PRESERVE_CLIPBOARD=0` | Отключить save/restore буфера обмена вокруг `pasteText` |
| `WEBTEST_HOOKS_RUNTIME=python` | Использовать py-версии скиллов вместо ps1 (для не-Windows) |
## Артефакты
- `tests/web-test/error-*.png` — скриншоты упавших шагов (auto на `--screenshot=on-failure`)
- `tests/web-test/_allure/` — Allure-результаты (на `--format=allure`)
- `tests/skills/.cache/webtest-stand/` — lockfiles стенда (config-hash, epf-hash, data-hash)
## Известные нюансы
- **`15-multi-context-handover`** создаёт `unique`-Контрагента и **сохраняет** — за серию прогонов накапливаются «лишние» записи. Если `02-crud` начал падать на «`ООО Север` должен быть в списке» — это симптом, лечится `-- --rebuild-stand`.
- **`04-selectvalue` auto-history шаг** — в изоляции делает warm-up через двойной `selectValue('Менеджер', 'ООО Юг')` чтобы наполнить history, иначе первый вызов идёт через `method:form`, а тест ожидает `method:dropdown`. Не зависит от других файлов.
- **Скриншот ошибки только на последнем падении** — `--screenshot=on-failure` (default) делает один кадр в момент исключения. Для full-trace используй `--screenshot=every-step`.