mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-19 00:59:40 +03:00
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:
@@ -333,12 +333,12 @@ Clear filters. Without arguments clears all, with `{ field }` clears specific ba
|
|||||||
#### `screenshot()` → PNG Buffer
|
#### `screenshot()` → PNG Buffer
|
||||||
#### `wait(seconds)` → form state
|
#### `wait(seconds)` → form state
|
||||||
#### `getPage()` → Playwright Page (raw, for advanced scripting)
|
#### `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
|
#### `showCaption(text, opts?)` / `hideCaption()` → text overlay on page
|
||||||
#### `showTitleSlide(text, opts?)` / `hideTitleSlide()` → full-screen title card (intro/outro)
|
#### `showTitleSlide(text, opts?)` / `hideTitleSlide()` → full-screen title card (intro/outro)
|
||||||
#### `isRecording()` → boolean
|
#### `isRecording()` → boolean
|
||||||
#### `setHighlight(on)` / `isHighlightMode()` → auto-highlight mode for video
|
#### `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
|
#### `addNarration(videoPath, opts?)` → narrated MP4 with TTS voiceover
|
||||||
#### `getCaptions()` → caption timestamps from last recording
|
#### `getCaptions()` → caption timestamps from last recording
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
/**
|
/**
|
||||||
* Playwright browser management for 1C web client.
|
* Playwright browser management for 1C web client.
|
||||||
@@ -1440,6 +1440,11 @@ export async function fillFields(fields) {
|
|||||||
return { filled: results, form: formData };
|
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). */
|
/** 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 } = {}) {
|
export async function clickElement(text, { dblclick, table, toggle } = {}) {
|
||||||
ensureConnected();
|
ensureConnected();
|
||||||
@@ -3526,7 +3531,13 @@ export function isRecording() {
|
|||||||
*/
|
*/
|
||||||
export async function startRecording(outputPath, opts = {}) {
|
export async function startRecording(outputPath, opts = {}) {
|
||||||
ensureConnected();
|
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 = [];
|
lastCaptions = [];
|
||||||
lastRecordingDuration = null;
|
lastRecordingDuration = null;
|
||||||
|
|
||||||
@@ -4063,8 +4074,8 @@ export async function highlight(text, opts = {}) {
|
|||||||
|
|
||||||
// 2. Form groups/panels — checked BEFORE buttons/fields because group names
|
// 2. Form groups/panels — checked BEFORE buttons/fields because group names
|
||||||
// often collide with command bar buttons (e.g. "БизнесПроцессы" is both a
|
// often collide with command bar buttons (e.g. "БизнесПроцессы" is both a
|
||||||
// panel and a command bar element). Groups are large visual containers;
|
// panel and a command bar element). Min-area filter (100x50) only for fuzzy
|
||||||
// min-area filter (100x50) prevents matching small elements.
|
// match — exact match by name works regardless of size (Representation=None).
|
||||||
if (!elId) {
|
if (!elId) {
|
||||||
const formNum = await page.evaluate(detectFormScript());
|
const formNum = await page.evaluate(detectFormScript());
|
||||||
if (formNum !== null) {
|
if (formNum !== null) {
|
||||||
@@ -4072,9 +4083,9 @@ export async function highlight(text, opts = {}) {
|
|||||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||||
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||||
const p = 'form' + ${formNum} + '_';
|
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"]')]
|
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 items = groups.map(el => {
|
||||||
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
|
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
|
||||||
// Try to find a visible title/label for this group
|
// 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');
|
|| document.getElementById(p + idName + '_title_text');
|
||||||
const label = norm(titleEl?.innerText || '').toLowerCase();
|
const label = norm(titleEl?.innerText || '').toLowerCase();
|
||||||
const name = norm(idName).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
|
// Exact match: no size filter (supports Representation=None groups)
|
||||||
// Skip includes() for short strings (< 4 chars) to avoid false positives
|
|
||||||
// e.g. "Да" matching "Удаляемые"
|
|
||||||
let found = items.find(i => i.label === target);
|
let found = items.find(i => i.label === target);
|
||||||
if (!found) found = items.find(i => i.name === target);
|
if (!found) found = items.find(i => i.name === target);
|
||||||
if (!found) found = items.find(i => i.label.startsWith(target) || i.name.startsWith(target));
|
// Fuzzy match: only large groups (min 100x50) to avoid matching command bars
|
||||||
if (!found && target.length >= 4) found = items.find(i => i.label.includes(target) || i.name.includes(target));
|
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;
|
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)
|
// Overlay div + rAF tracking loop (not clipped by overflow:hidden, follows layout shifts)
|
||||||
await page.evaluate(({ elId, color, padding }) => {
|
await page.evaluate(({ elId, color, padding }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
/**
|
/**
|
||||||
* CLI runner for 1C web client automation.
|
* 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)
|
// Wrap action functions to auto-detect 1C errors (modal, balloon)
|
||||||
// and stop execution immediately with diagnostic info
|
// and stop execution immediately with diagnostic info
|
||||||
const ACTION_FNS = [
|
const ACTION_FNS = [
|
||||||
'clickElement', 'fillFields', 'selectValue', 'fillTableRow',
|
'clickElement', 'fillFields', 'fillField', 'selectValue', 'fillTableRow',
|
||||||
'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile',
|
'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile',
|
||||||
'closeForm', 'filterList', 'unfilterList'
|
'closeForm', 'filterList', 'unfilterList'
|
||||||
];
|
];
|
||||||
@@ -162,6 +162,11 @@ async function executeScript(code, { noRecord } = {}) {
|
|||||||
console.log = origLog;
|
console.log = origLog;
|
||||||
console.error = origErr;
|
console.error = origErr;
|
||||||
|
|
||||||
|
// Auto-stop recording if active (prevents "Already recording" on next exec)
|
||||||
|
if (browser.isRecording()) {
|
||||||
|
try { await browser.stopRecording(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// Error screenshot
|
// Error screenshot
|
||||||
let shotFile;
|
let shotFile;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user