Merge branch 'clipboard-preserve' into dev

This commit is contained in:
Nick Shirokov
2026-05-25 20:12:53 +03:00
4 changed files with 131 additions and 34 deletions
+1 -1
View File
@@ -531,7 +531,7 @@ On error (auto-screenshot taken):
- **Headed mode** — 1C requires visible browser, no headless - **Headed mode** — 1C requires visible browser, no headless
- **Startup time** — 1C loads 30-60s on initial connect (built into `start`) - **Startup time** — 1C loads 30-60s on initial connect (built into `start`)
- **Fuzzy matching** — all name lookups: exact > startsWith > includes - **Fuzzy matching** — all name lookups: exact > startsWith > includes
- **Clipboard paste** — all text fields filled via Ctrl+V (triggers 1C events properly) - **Clipboard paste** — all text fields filled via Ctrl+V (triggers 1C events properly). The OS clipboard is automatically saved before each action and restored after, so a local user's clipboard survives a test run. Opt out with `--no-preserve-clipboard` (any command), `WEB_TEST_PRESERVE_CLIPBOARD=0` env, or `preserveClipboard: false` in `webtest.config.mjs`
- **Cyrillic in bash** — use `cat <<'SCRIPT' | node $RUN exec -` to avoid escaping issues - **Cyrillic in bash** — use `cat <<'SCRIPT' | node $RUN exec -` to avoid escaping issues
- **Non-breaking spaces** — 1C uses `\u00a0` instead of regular spaces. All matching is normalized internally - **Non-breaking spaces** — 1C uses `\u00a0` instead of regular spaces. All matching is normalized internally
- **Section panel display** — `navigateSection()` works with any panel position (side, top) but requires "Picture and text" or "Text" display mode. Icon-only mode is not supported — API cannot read section names from icons alone - **Section panel display** — `navigateSection()` works with any panel position (side, top) but requires "Picture and text" or "Text" display mode. Icon-only mode is not supported — API cannot read section names from icons alone
+107 -32
View File
@@ -1,4 +1,4 @@
// web-test browser v1.12 — Playwright browser management for 1C web client // web-test browser v1.16 — 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.
@@ -61,6 +61,98 @@ const STABLE_CYCLES = 3; // consecutive stable cycles needed
const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik'; const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik';
let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect 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 — narrow window so a user's
// concurrent Ctrl+C isn't clobbered. Blobs are stashed on `window` (no CDP
// serialization). Toggled via setPreserveClipboard() from run.mjs.
let preserveClipboard = true;
let clipboardWarnLogged = false;
export function setPreserveClipboard(v) { preserveClipboard = !!v; }
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) {
clipboardWarnLogged = 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();
}
}
/** /**
* Find the 1C browser extension in Chrome/Edge user profiles. * Find the 1C browser extension in Chrome/Edge user profiles.
* Returns the path to the latest version, or null if not found. * Returns the path to the latest version, or null if not found.
@@ -1137,8 +1229,7 @@ export async function navigateLink(url) {
const formBefore = await page.evaluate(detectFormScript()); const formBefore = await page.evaluate(detectFormScript());
// Copy link to clipboard, press Shift+F11 (opens "Go to link" dialog with clipboard content) // Copy link to clipboard, press Shift+F11 (opens "Go to link" dialog with clipboard content)
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(link)})`); await pasteText(link, { confirm: 'Shift+F11', postDelay: 200 });
await page.keyboard.press('Shift+F11');
await waitForStable(); await waitForStable();
// Click "Перейти" in the navigation dialog // Click "Перейти" in the navigation dialog
@@ -1868,8 +1959,7 @@ async function advancedSearchInline(formNum, text) {
await page.click(`[id="${patternId}"]`); await page.click(`[id="${patternId}"]`);
await page.waitForTimeout(200); await page.waitForTimeout(200);
await page.keyboard.press('Control+A'); await page.keyboard.press('Control+A');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); await pasteText(text);
await page.keyboard.press('Control+V');
await page.waitForTimeout(300); await page.waitForTimeout(300);
// 4. Click "Найти" // 4. Click "Найти"
@@ -1971,8 +2061,7 @@ async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum)
await page.click(`[id="${searchInputId}"]`); await page.click(`[id="${searchInputId}"]`);
await page.waitForTimeout(200); await page.waitForTimeout(200);
await page.keyboard.press('Control+A'); await page.keyboard.press('Control+A');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(searchText))})`); await pasteText(searchText);
await page.keyboard.press('Control+V');
await page.waitForTimeout(300); await page.waitForTimeout(300);
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await waitForStable(selFormNum); await waitForStable(selFormNum);
@@ -2112,8 +2201,7 @@ async function pickFromTypeDialog(formNum, typeName) {
// Paste search text (focus is on "Что искать" field) // Paste search text (focus is on "Что искать" field)
await page.keyboard.press('Control+a'); await page.keyboard.press('Control+a');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(typeName)})`); await pasteText(typeName);
await page.keyboard.press('Control+v');
await page.waitForTimeout(300); await page.waitForTimeout(300);
// Find the "Найти" dialog form number (it's > formNum) // Find the "Найти" dialog form number (it's > formNum)
@@ -2302,8 +2390,7 @@ async function fillReferenceField(selector, fieldName, value, formNum) {
} }
// 3. Paste text via clipboard (trusted event → triggers real 1C autocomplete) // 3. Paste text via clipboard (trusted event → triggers real 1C autocomplete)
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`); await pasteText(text);
await page.keyboard.press('Control+V');
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
// 4. Check editDropDown for autocomplete suggestions // 4. Check editDropDown for autocomplete suggestions
@@ -2518,8 +2605,7 @@ export async function fillFields(fields) {
await page.click(selector); await page.click(selector);
await page.waitForTimeout(200); await page.waitForTimeout(200);
await page.keyboard.press('Control+A'); await page.keyboard.press('Control+A');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(fields[r.field]))})`); await pasteText(fields[r.field]);
await page.keyboard.press('Control+V');
await page.waitForTimeout(300); await page.waitForTimeout(300);
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
await waitForStable(); await waitForStable();
@@ -2539,8 +2625,7 @@ export async function fillFields(fields) {
await page.click(selector); await page.click(selector);
await page.waitForTimeout(200); await page.waitForTimeout(200);
await page.keyboard.press('Control+A'); await page.keyboard.press('Control+A');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(fields[r.field]))})`); await pasteText(fields[r.field]);
await page.keyboard.press('Control+V');
await page.waitForTimeout(300); await page.waitForTimeout(300);
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
await waitForStable(); await waitForStable();
@@ -3818,9 +3903,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) {
})()`); })()`);
if (selForm === null && inInputAfterDblclick) { if (selForm === null && inInputAfterDblclick) {
// Plain text/numeric field — fill via clipboard paste // Plain text/numeric field — fill via clipboard paste
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(info.value)})`); await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] });
await page.keyboard.press('Control+a');
await page.keyboard.press('Control+v');
await page.waitForTimeout(400); await page.waitForTimeout(400);
// Dismiss EDD autocomplete if it appeared // Dismiss EDD autocomplete if it appeared
const hasEdd = await page.evaluate(`(() => { const hasEdd = await page.evaluate(`(() => {
@@ -4135,9 +4218,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) {
} }
} }
} }
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`); await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
await page.keyboard.press('Control+a');
await page.keyboard.press('Control+v');
await page.waitForTimeout(400); await page.waitForTimeout(400);
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
await page.waitForTimeout(300); await page.waitForTimeout(300);
@@ -4169,8 +4250,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) {
// === Fill this cell: clipboard paste (trusted event) === // === Fill this cell: clipboard paste (trusted event) ===
await page.keyboard.press('Control+A'); await page.keyboard.press('Control+A');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`); await pasteText(text);
await page.keyboard.press('Control+V');
await page.waitForTimeout(1500); await page.waitForTimeout(1500);
// Check if paste was rejected (composite-type cell blocks text input until type is selected) // Check if paste was rejected (composite-type cell blocks text input until type is selected)
@@ -4421,9 +4501,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) {
} }
} }
} }
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`); await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
await page.keyboard.press('Control+a');
await page.keyboard.press('Control+v');
await page.waitForTimeout(400); await page.waitForTimeout(400);
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
await page.waitForTimeout(300); await page.waitForTimeout(300);
@@ -4668,8 +4746,7 @@ export async function filterList(text, { field, exact } = {}) {
await page.click(`[id="${searchId}"]`); await page.click(`[id="${searchId}"]`);
await page.waitForTimeout(200); await page.waitForTimeout(200);
await page.keyboard.press('Control+A'); await page.keyboard.press('Control+A');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); await pasteText(text);
await page.keyboard.press('Control+V');
await page.waitForTimeout(300); await page.waitForTimeout(300);
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await waitForStable(formNum); await waitForStable(formNum);
@@ -4849,8 +4926,7 @@ export async function filterList(text, { field, exact } = {}) {
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.keyboard.press('Shift+End'); await page.keyboard.press('Shift+End');
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); await pasteText(text);
await page.keyboard.press('Control+V');
await page.waitForTimeout(500); await page.waitForTimeout(500);
} }
} else { } else {
@@ -4858,8 +4934,7 @@ export async function filterList(text, { field, exact } = {}) {
await page.click(`[id="${dialogInfo.patternId}"]`); await page.click(`[id="${dialogInfo.patternId}"]`);
await page.waitForTimeout(200); await page.waitForTimeout(200);
await page.keyboard.press('Control+A'); await page.keyboard.press('Control+A');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); await pasteText(text);
await page.keyboard.press('Control+V');
await page.waitForTimeout(300); await page.waitForTimeout(300);
if (dialogInfo.isRef) { if (dialogInfo.isRef) {
+17 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
// web-test run v1.14 — CLI runner for 1C web client automation // web-test run v1.16 — 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.
@@ -39,6 +39,14 @@ const flags = {
}; };
const args = rawArgs.filter(a => !a.startsWith('--')); const args = rawArgs.filter(a => !a.startsWith('--'));
// Clipboard preservation: default ON. Disabled by --no-preserve-clipboard CLI flag
// or WEB_TEST_PRESERVE_CLIPBOARD=0 env. cmdTest may further disable via config.
// Forwarded to browser.setPreserveClipboard() — narrow save/restore lives around
// each writeText+Ctrl+V pair inside pasteText() in browser.mjs.
const preserveClipboard = !rawArgs.includes('--no-preserve-clipboard')
&& process.env.WEB_TEST_PRESERVE_CLIPBOARD !== '0';
browser.setPreserveClipboard(preserveClipboard);
function parseExecTimeoutMs(argv) { function parseExecTimeoutMs(argv) {
const DEFAULT_MS = 30 * 60 * 1000; const DEFAULT_MS = 30 * 60 * 1000;
const flagMs = argv.find(a => a.startsWith('--timeout=')); const flagMs = argv.find(a => a.startsWith('--timeout='));
@@ -449,6 +457,10 @@ async function cmdTest(rawArgs) {
if (!tags && config.tags) tags = config.tags; if (!tags && config.tags) tags = config.tags;
opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout); 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); opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry);
// Clipboard preservation: CLI flag wins (already applied at boot), else config can disable.
if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) {
browser.setPreserveClipboard(false);
}
opts.record = opts.record || !!config.record; opts.record = opts.record || !!config.record;
opts.screenshot = opts.screenshot || config.screenshot || 'on-failure'; opts.screenshot = opts.screenshot || config.screenshot || 'on-failure';
if (!['on-failure', 'every-step', 'off'].includes(opts.screenshot)) { if (!['on-failure', 'every-step', 'off'].includes(opts.screenshot)) {
@@ -1225,6 +1237,10 @@ Commands:
Options for exec: Options for exec:
--no-record Skip video recording (record() becomes no-op) --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: Options for test:
--tags=smoke,crud Filter tests by tags --tags=smoke,crud Filter tests by tags
--grep=pattern Filter tests by name (regex) --grep=pattern Filter tests by name (regex)
+6
View File
@@ -21,6 +21,12 @@ export default {
// extension may not load (Playwright limitation). Use only when really needed. // extension may not load (Playwright limitation). Use only when really needed.
timeout: 60000, timeout: 60000,
// OS clipboard preservation: default `true`. Around every action call the engine
// saves the full clipboard contents (any MIME types via `navigator.clipboard.read()`)
// and restores them after, so a local user can copy/paste in parallel with a test run.
// Set to `false` to disable for this suite. CLI flag `--no-preserve-clipboard` overrides.
preserveClipboard: true,
// Allure severity policy: inverted map "уровень → теги, попадающие в этот уровень". // Allure severity policy: inverted map "уровень → теги, попадающие в этот уровень".
// Резолв (run.mjs:resolveSeverity): // Резолв (run.mjs:resolveSeverity):
// 1. explicit `export const severity` в тесте — выигрывает всегда; // 1. explicit `export const severity` в тесте — выигрывает всегда;