pcbjam/tests/kicad/eeschema-copy-paste.spec.ts
Istvan Matejcsok 6034a0550d eeschema copy/paste fix: wx UTF-8 text dataobj (c1f1477) + regression specs
Cmd+C/Cmd+V in the wasm eeschema pasted a stray "(" SCH_TEXT — the stroke
font renders it as a small blue arc — instead of the copied symbol, and any
copy reached navigator.clipboard as a single character. Root cause and fix
live in wxwidgets (90bbad29822: UTF-8 wxDF_UNICODETEXT + the port's missing
GetAllFormats key function; c1f14775ba3: empty-read cache fallback + no
browser pre-clear on copy). This bump carries them plus the guards:

- tests/kicad/eeschema-copy-paste.spec.ts: chromium asserts the full
  multi-form (lib_symbols …)(symbol …) blob reaches navigator.clipboard;
  both engines assert copy → paste → save yields a second symbol and no
  stray (text …) item (firefox exercises the m_textCache fallback path).
  The kicad-chromium project now grants clipboard-read/write. NB the kicad
  projects' device UAs claim Windows, so specs must send plain Control+…
  — ControlOrMeta resolves to Meta on a mac host, which the "Windows" app
  ignores, and the bare key fires eeschema hotkeys instead.
- tests/e2e/textdataobj.spec.ts + Makefile.wasm target `textdataobj`:
  drives the wx-repo harness app (wxwidgets/tests/wasm/textdataobj_test.cpp)
  and asserts the SUITE-DONE failure counters plus the full-string browser
  round-trip, sentinel-seeded so a failed write cannot read as stale success.
- tests/e2e/clipboard.spec.ts: the Copy test now asserts the full input
  text reaches the clipboard (previously truncated to "S").

Verified locally: all specs red on the old wx, green after; wx-chromium
320/320, kicad-chromium 121/121 (collab specs env-gated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 11:21:00 +02:00

220 lines
9.2 KiB
TypeScript

import { test, expect } from './fixtures';
import { stableShot } from '../e2e/utils/element-tracker';
/**
* Eeschema copy/paste regression (wasm clipboard truncation).
*
* The bug: without wxNEEDS_UTF8_FOR_TEXT_DATAOBJ the wasm port's
* wxTextDataObject served UTF-32 for wxDF_UNICODETEXT; wxClipboard::AddData
* read it as UTF-8 (ASCII-in-UTF-32 = "valid" UTF-8 full of NULs) and the JS
* write helper's UTF8ToString truncated at the first NUL. Cmd+C left exactly
* "(" on navigator.clipboard; Cmd+V then failed the s-expression parse and
* eeschema's fallback pasted a stray SCH_TEXT("(") — rendered by the stroke
* font as a small blue arc instead of the copied symbol.
*
* Two layers:
* - chromium-only: the copied s-expression must reach the browser clipboard
* IN FULL (cross-instance copy/paste transport).
* - both engines: copy → paste → save must yield a second symbol and no
* stray text item. On firefox (no clipboard permission grants) the paste
* exercises the wxClipboard m_textCache fallback; on chromium the real
* browser clipboard — both transports covered.
*
* The wx-API-level contract has its own harness:
* wxwidgets/tests/wasm/textdataobj_test.cpp + e2e/textdataobj.spec.ts.
*/
const SAMPLE_SCH = `(kicad_sch
\t(version 20250114)
\t(generator "eeschema")
\t(generator_version "9.0")
\t(uuid "aaaaaaaa-1111-1111-1111-111111111111")
\t(paper "A4")
\t(lib_symbols
\t\t(symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes)
\t\t\t(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
\t\t\t(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
\t\t\t(property "Footprint" "" (at -1.778 0 90) (effects (font (size 1.27 1.27)) hide))
\t\t\t(property "Datasheet" "~" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
\t\t\t(symbol "R_0_1"
\t\t\t\t(rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none)))
\t\t\t)
\t\t\t(symbol "R_1_1"
\t\t\t\t(pin passive line (at 0 3.81 270) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
\t\t\t\t(pin passive line (at 0 -3.81 90) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
\t\t\t)
\t\t)
\t)
\t(symbol (lib_id "Device:R") (at 127 95.25 0) (unit 1) (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no)
\t\t(uuid "bbbbbbbb-2222-2222-2222-222222222222")
\t\t(property "Reference" "R1" (at 129.54 94.615 0) (effects (font (size 1.27 1.27)) (justify left)))
\t\t(property "Value" "10k" (at 129.54 96.52 0) (effects (font (size 1.27 1.27)) (justify left)))
\t\t(property "Footprint" "" (at 125.222 95.25 90) (effects (font (size 1.27 1.27)) hide))
\t\t(property "Datasheet" "~" (at 127 95.25 0) (effects (font (size 1.27 1.27)) hide))
\t\t(pin "1" (uuid "cccccccc-0000-0000-0000-000000000001"))
\t\t(pin "2" (uuid "cccccccc-0000-0000-0000-000000000002"))
\t\t(instances
\t\t\t(project "copypaste"
\t\t\t\t(path "/aaaaaaaa-1111-1111-1111-111111111111" (reference "R1") (unit 1))
\t\t\t)
\t\t)
\t)
\t(sheet_instances (path "/" (page "1")))
)
`;
const SCH_PATH = '/home/kicad/documents/copypaste.kicad_sch';
type EmscriptenFS = {
mkdirTree(path: string): void;
writeFile(path: string, data: string): void;
readFile(path: string): Uint8Array;
};
type KicadModule = { kicadOpenFile(path: string): unknown };
type WxWindow = Window & { FS: EmscriptenFS; Module: KicadModule };
async function bootWithSchematic(page: import('@playwright/test').Page) {
await page.goto('/kicad/eeschema.html');
// Same boot gates as eeschema-load.spec.ts: canvas, element registry,
// the embind open hook, and a top-level Frame.
await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
await page.waitForFunction(
() =>
typeof (window as unknown as { Module?: KicadModule }).Module?.kicadOpenFile ===
'function',
null,
{ timeout: 90000 }
);
await page.waitForFunction(
() =>
!!window.wxElementRegistry &&
window.wxElementRegistry
.findAll({ visible: true })
.some((e) => /Frame$/.test(e.typeName) || (e.name || '').endsWith('Frame')),
null,
{ timeout: 90000 }
);
await page.evaluate(
({ content, path }) => {
const w = window as unknown as WxWindow;
try {
w.FS.mkdirTree('/home/kicad/documents');
} catch {
/* already exists */
}
w.FS.writeFile(path, content);
w.Module.kicadOpenFile(path);
},
{ content: SAMPLE_SCH, path: SCH_PATH }
);
await expect
.poll(async () => page.title(), {
message: 'schematic load did not complete (title stayed untitled)',
timeout: 30000,
intervals: [500],
})
.toMatch(/copypaste/i);
// Focus the schematic drawing area so hotkeys land in the editor. The
// whole wx UI renders inside the one #canvas element — the left-hand
// panels are wx widgets, so the click must land right of them
// (drawing area ≈ x 340-1250, y 90-660 at the 1280x720 viewport).
await page.mouse.click(700, 400);
await page.waitForTimeout(500);
}
// The kicad projects run under devices['Desktop Chrome'/'Desktop Firefox'],
// whose user agents claim Windows; the wasm port's mac-detection follows the
// UA (wxGetOsVersion → platformInfo.name), so the editor maps Control from
// ctrlKey. Plain 'Control+…' is therefore correct on every host OS here —
// 'ControlOrMeta' would send Meta on a mac host, which the "Windows" app
// ignores (observed: 'a' alone opened the Place Symbol dialog).
async function selectAllAndCopy(page: import('@playwright/test').Page) {
await page.keyboard.press('Control+a');
await page.waitForTimeout(800);
await page.keyboard.press('Control+c');
// The clipboard write suspends via JSPI (browser round-trip, 2s timeout
// budget inside wxClipboard) — give it room before acting on the result.
await page.waitForTimeout(2500);
}
test.describe('Eeschema copy/paste', () => {
test('Cmd+C puts the full schematic s-expression on the browser clipboard', async ({
page,
browserName,
}) => {
test.skip(browserName === 'firefox', 'firefox cannot grant clipboard read/write to tests');
await bootWithSchematic(page);
// Seed known clipboard state so a failed write cannot pass as success.
await page.evaluate(() => navigator.clipboard.writeText('SENTINEL-BEFORE-COPY'));
await selectAllAndCopy(page);
const clip = await page.evaluate(() => navigator.clipboard.readText());
// eeschema's clipboard blob is the multi-form shape
// `(lib_symbols ...)\n(symbol ...)` (see pcbjam-shared items-wire.ts).
// The original bug left exactly "(" here (UTF-32 truncated at its
// first NUL by the clipboard write helper).
expect(clip).toMatch(/^\(lib_symbols/);
expect(clip).toContain('(symbol "Device:R"');
expect(clip).toContain('(lib_id "Device:R")');
});
test('copy → paste → save yields a second symbol and no stray text item', async ({
page,
}) => {
await bootWithSchematic(page);
await selectAllAndCopy(page);
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
// Paste attaches the items to the cursor; click commits the placement.
await page.mouse.move(500, 300);
await page.keyboard.press('Control+v');
await page.waitForTimeout(2500);
await stableShot(page, 'eeschema-copy-paste-preview.png');
await page.mouse.click(500, 300);
await page.waitForTimeout(800);
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await page.keyboard.press('Control+s');
await expect
.poll(
async () => {
const content = await page.evaluate((path) => {
const w = window as unknown as WxWindow;
return new TextDecoder().decode(w.FS.readFile(path));
}, SCH_PATH);
return (content.match(/\(lib_id "Device:R"\)/g) || []).length;
},
{
message:
'saved schematic should contain the original AND the pasted symbol ' +
'(the truncation bug pasted a stray "(" text item instead)',
timeout: 20000,
intervals: [1000],
}
)
.toBe(2);
const content = await page.evaluate((path) => {
const w = window as unknown as WxWindow;
return new TextDecoder().decode(w.FS.readFile(path));
}, SCH_PATH);
// The failure mode of the truncation bug: an unparseable clipboard
// pastes as a SCH_TEXT (serialized as a (text …) node).
expect(content).not.toContain('(text "');
await stableShot(page, 'eeschema-copy-paste-committed.png');
});
});