feat(web-test): highlight groups fix, recording auto-stop, fillField alias

- highlight(): exact match by name ignores size filter (supports Representation=None groups),
  error message lists available elements by category
- startRecording(): { force: true } option to restart if already recording
- executeScript(): auto-stop recording on script error (prevents "Already recording")
- fillField(name, value): silent alias for fillFields({ name: value })

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-03-15 15:37:21 +03:00
parent e3a9be0036
commit ca681676b4
3 changed files with 82 additions and 17 deletions
+2 -2
View File
@@ -333,12 +333,12 @@ Clear filters. Without arguments clears all, with `{ field }` clears specific ba
#### `screenshot()` → PNG Buffer
#### `wait(seconds)` → form state
#### `getPage()` → Playwright Page (raw, for advanced scripting)
#### `startRecording(path, opts?)` / `stopRecording()` → MP4 video recording
#### `startRecording(path, opts?)` / `stopRecording()` → MP4 video recording (`{ force: true }` to restart if already recording)
#### `showCaption(text, opts?)` / `hideCaption()` → text overlay on page
#### `showTitleSlide(text, opts?)` / `hideTitleSlide()` → full-screen title card (intro/outro)
#### `isRecording()` → boolean
#### `setHighlight(on)` / `isHighlightMode()` → auto-highlight mode for video
#### `highlight(text)` / `unhighlight()` → manual element highlighting
#### `highlight(text)` / `unhighlight()` → manual element highlighting (error lists available elements)
#### `addNarration(videoPath, opts?)` → narrated MP4 with TTS voiceover
#### `getCaptions()` → caption timestamps from last recording
+73 -13
View File
@@ -1,4 +1,4 @@
// web-test browser v1.0 — Playwright browser management for 1C web client
// web-test browser v1.2 — Playwright browser management for 1C web client
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Playwright browser management for 1C web client.
@@ -1440,6 +1440,11 @@ export async function fillFields(fields) {
return { filled: results, form: formData };
}
/** Convenience alias: fill a single field. Same as fillFields({ name: value }). */
export async function fillField(name, value) {
return fillFields({ [name]: value });
}
/** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists). */
export async function clickElement(text, { dblclick, table, toggle } = {}) {
ensureConnected();
@@ -3526,7 +3531,13 @@ export function isRecording() {
*/
export async function startRecording(outputPath, opts = {}) {
ensureConnected();
if (recorder) throw new Error('Already recording. Call stopRecording() first.');
if (recorder) {
if (opts.force) {
try { await stopRecording(); } catch {}
} else {
throw new Error('Already recording. Call stopRecording() first, or use { force: true }.');
}
}
lastCaptions = [];
lastRecordingDuration = null;
@@ -4063,8 +4074,8 @@ export async function highlight(text, opts = {}) {
// 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). Groups are large visual containers;
// min-area filter (100x50) prevents matching small elements.
// panel and a command bar element). Min-area filter (100x50) only for fuzzy
// match — exact match by name works regardless of size (Representation=None).
if (!elId) {
const formNum = await page.evaluate(detectFormScript());
if (formNum !== null) {
@@ -4072,9 +4083,9 @@ export async function highlight(text, opts = {}) {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
const p = 'form' + ${formNum} + '_';
// Collect visible group containers — _container or _div elements (min 100x50 to skip command bars)
// Collect ALL visible group containers — _container or _div elements
const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
.filter(el => el.offsetWidth >= 100 && el.offsetHeight >= 50);
.filter(el => el.offsetWidth > 0);
const items = groups.map(el => {
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
// Try to find a visible title/label for this group
@@ -4082,15 +4093,15 @@ export async function highlight(text, opts = {}) {
|| document.getElementById(p + idName + '_title_text');
const label = norm(titleEl?.innerText || '').toLowerCase();
const name = norm(idName).toLowerCase();
return { id: el.id, name, label };
const big = el.offsetWidth >= 100 && el.offsetHeight >= 50;
return { id: el.id, name, label, big };
});
// Fuzzy match: exact label → exact name → startsWith → includes
// Skip includes() for short strings (< 4 chars) to avoid false positives
// e.g. "Да" matching "Удаляемые"
// Exact match: no size filter (supports Representation=None groups)
let found = items.find(i => i.label === target);
if (!found) found = items.find(i => i.name === target);
if (!found) found = items.find(i => i.label.startsWith(target) || i.name.startsWith(target));
if (!found && target.length >= 4) found = items.find(i => i.label.includes(target) || i.name.includes(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;
})()`);
}
@@ -4157,7 +4168,56 @@ export async function highlight(text, opts = {}) {
})()`);
}
if (!elId) throw new Error(`highlight: "${text}" not found`);
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;
// 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
const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
.filter(el => el.offsetWidth > 0)
.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 }) => {
+7 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// web-test run v1.1 — CLI runner for 1C web client automation
// web-test run v1.2 — CLI runner for 1C web client automation
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* CLI runner for 1C web client automation.
@@ -131,7 +131,7 @@ async function executeScript(code, { noRecord } = {}) {
// Wrap action functions to auto-detect 1C errors (modal, balloon)
// and stop execution immediately with diagnostic info
const ACTION_FNS = [
'clickElement', 'fillFields', 'selectValue', 'fillTableRow',
'clickElement', 'fillFields', 'fillField', 'selectValue', 'fillTableRow',
'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile',
'closeForm', 'filterList', 'unfilterList'
];
@@ -162,6 +162,11 @@ async function executeScript(code, { noRecord } = {}) {
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
let shotFile;
try {