feat(web-test): сохранять и восстанавливать буфер обмена вокруг паст

Тесты активно используют OS clipboard (`writeText` + Ctrl+V — единственный
способ добиться trusted-paste для autocomplete справочников и кириллицы).
При локальном запуске это перетирало пользовательский буфер. Теперь:

- `pasteText(text, {confirm, postDelay})` в browser.mjs делает узкое окно
  save → writeText → confirm-key → restore вокруг каждой пасты (~ms).
- Save/restore через `navigator.clipboard.read()`/`write()` — все MIME
  (текст, картинка, HTML), blob'ы стэшатся на `window` без CDP-сериализации.
- 14 callsites переведены на helper.
- При failure save'а (CF_HDROP из Проводника не виден через web-API) restore
  явно очищает буфер, чтобы тестовое значение не протекало.
- Опт-аут: CLI `--no-preserve-clipboard`, env `WEB_TEST_PRESERVE_CLIPBOARD=0`,
  `preserveClipboard: false` в `webtest.config.mjs`.

Регресс tests/web-test — 6 прогонов 19/19 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-05-25 20:12:14 +03:00
parent 60cdbf0aec
commit bb2f8fb29e
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
- **Startup time** — 1C loads 30-60s on initial connect (built into `start`)
- **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
- **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
+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
/**
* Playwright browser management for 1C web client.
@@ -61,6 +61,98 @@ const STABLE_CYCLES = 3; // consecutive stable cycles needed
const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik';
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.
* 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());
// 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 page.keyboard.press('Shift+F11');
await pasteText(link, { confirm: 'Shift+F11', postDelay: 200 });
await waitForStable();
// Click "Перейти" in the navigation dialog
@@ -1868,8 +1959,7 @@ async function advancedSearchInline(formNum, text) {
await page.click(`[id="${patternId}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
await page.keyboard.press('Control+V');
await pasteText(text);
await page.waitForTimeout(300);
// 4. Click "Найти"
@@ -1971,8 +2061,7 @@ async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum)
await page.click(`[id="${searchInputId}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(searchText))})`);
await page.keyboard.press('Control+V');
await pasteText(searchText);
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await waitForStable(selFormNum);
@@ -2112,8 +2201,7 @@ async function pickFromTypeDialog(formNum, typeName) {
// Paste search text (focus is on "Что искать" field)
await page.keyboard.press('Control+a');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(typeName)})`);
await page.keyboard.press('Control+v');
await pasteText(typeName);
await page.waitForTimeout(300);
// 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)
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`);
await page.keyboard.press('Control+V');
await pasteText(text);
await page.waitForTimeout(2000);
// 4. Check editDropDown for autocomplete suggestions
@@ -2518,8 +2605,7 @@ export async function fillFields(fields) {
await page.click(selector);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(fields[r.field]))})`);
await page.keyboard.press('Control+V');
await pasteText(fields[r.field]);
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await waitForStable();
@@ -2539,8 +2625,7 @@ export async function fillFields(fields) {
await page.click(selector);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(fields[r.field]))})`);
await page.keyboard.press('Control+V');
await pasteText(fields[r.field]);
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await waitForStable();
@@ -3818,9 +3903,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) {
})()`);
if (selForm === null && inInputAfterDblclick) {
// Plain text/numeric field — fill via clipboard paste
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(info.value)})`);
await page.keyboard.press('Control+a');
await page.keyboard.press('Control+v');
await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] });
await page.waitForTimeout(400);
// Dismiss EDD autocomplete if it appeared
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 page.keyboard.press('Control+a');
await page.keyboard.press('Control+v');
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
await page.waitForTimeout(400);
await page.keyboard.press('Tab');
await page.waitForTimeout(300);
@@ -4169,8 +4250,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) {
// === Fill this cell: clipboard paste (trusted event) ===
await page.keyboard.press('Control+A');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`);
await page.keyboard.press('Control+V');
await pasteText(text);
await page.waitForTimeout(1500);
// 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 page.keyboard.press('Control+a');
await page.keyboard.press('Control+v');
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
await page.waitForTimeout(400);
await page.keyboard.press('Tab');
await page.waitForTimeout(300);
@@ -4668,8 +4746,7 @@ export async function filterList(text, { field, exact } = {}) {
await page.click(`[id="${searchId}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
await page.keyboard.press('Control+V');
await pasteText(text);
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await waitForStable(formNum);
@@ -4849,8 +4926,7 @@ export async function filterList(text, { field, exact } = {}) {
await page.waitForTimeout(100);
await page.keyboard.press('Shift+End');
await page.waitForTimeout(100);
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
await page.keyboard.press('Control+V');
await pasteText(text);
await page.waitForTimeout(500);
}
} else {
@@ -4858,8 +4934,7 @@ export async function filterList(text, { field, exact } = {}) {
await page.click(`[id="${dialogInfo.patternId}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
await page.keyboard.press('Control+V');
await pasteText(text);
await page.waitForTimeout(300);
if (dialogInfo.isRef) {
+17 -1
View File
@@ -1,5 +1,5 @@
#!/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
/**
* CLI runner for 1C web client automation.
@@ -39,6 +39,14 @@ const flags = {
};
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) {
const DEFAULT_MS = 30 * 60 * 1000;
const flagMs = argv.find(a => a.startsWith('--timeout='));
@@ -449,6 +457,10 @@ async function cmdTest(rawArgs) {
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);
// 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.screenshot = opts.screenshot || config.screenshot || 'on-failure';
if (!['on-failure', 'every-step', 'off'].includes(opts.screenshot)) {
@@ -1225,6 +1237,10 @@ Commands:
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)
+6
View File
@@ -21,6 +21,12 @@ export default {
// extension may not load (Playwright limitation). Use only when really needed.
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 "уровень → теги, попадающие в этот уровень".
// Резолв (run.mjs:resolveSeverity):
// 1. explicit `export const severity` в тесте — выигрывает всегда;