mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-10 16:14:54 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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` в тесте — выигрывает всегда;
|
||||
|
||||
Reference in New Issue
Block a user