Add an end-to-end engine that drives the app against throwaway state - #362
Merged
Conversation
Nothing in the suite has ever launched the app. The Git modules are covered against real repositories and the IPC handlers against a loaded main process, but the flows a contributor actually performs — linking a ticket, applying a patch, moving between branches — are verified only by a person following the "How to test this" section of a pull request. This is the plumbing for closing that gap, ahead of the git-native refactor in #350: the behaviour that refactor moves has to be pinned before it moves, or the suite that results only records the refactor. Three problems had to be solved before any flow can be asserted on, and none of them are about the flow: - The app must never touch the contributor's own site registry. Every launch goes through the development-only data-directory redirect, and the harness reads back the path the app actually chose and refuses to continue if it is any other one. - Teardown must always terminate. Closing is known to hang on Windows in apps that keep child processes alive, which this one does constantly. - A failure has to leave evidence, and in this app the interesting half of a failure is on disk rather than on screen — so a failing journey attaches the screen and the state the app had persisted. Two suites, because there are two different apps under test. The journeys drive the source tree and write real state; the packaged smoke test launches an unsigned artifact and asks only whether packaging worked. Both run on macOS and Windows for every non-draft pull request. Neither downloads a browser. The packaged smoke test is the one from the closed #70, rebuilt rather than rebased. Two of its assertions had gone stale in ways that mattered: the list of bridged keys it compares against had roughly half the entries the app exposes today, and the native module it probes is no longer optional and no longer absent on Windows, so its deliberate Windows exclusion had become a hole. Running it also turned up that two bridged keys are spread in from an immediately invoked function, so no static reading of the preload file can find them — which is the same class of gap that assertion exists to close. Part of #359. Closes #360. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 18, 2026
juanmaguitar
added a commit
that referenced
this pull request
Aug 19, 2026
**Stacked on #362.** Review that one first; this diff is only the three files under `e2e/` that this PR adds. ## Why #362 built an engine and proved it works. This is the first thing it was built for: the ticket-branch flow, driven through the app rather than through a module. That flow is what a contributor spends a Contributor Day inside — link a ticket, edit a file, realise it belongs to another ticket, come back later — and it is the flow #350 moves wholesale. Today it is verified by a person following a **How to test this** section. After this, it is verified on macOS and Windows on every pull request. It is also the flow whose failures are silent. Work that is not restored produces no error; the app shows a clean tree and the contributor discovers the loss when a reviewer asks where the change went. ## What changes Four journeys, and one addition to the engine. - **Linking a ticket** creates its branch, checks it out, and leaves the gitignored substrate alone. - **Unlinking parks the work**, and the next ticket starts from trunk with none of the first one's edits. - **Switching back restores the work byte for byte** — the edit *and* the deletion. An edit that returns while a deletion does not is a half-restored tree, which is worse than an obvious failure. - **Deleting a ticket's work** removes that branch and only that branch, after asking. **Every assertion is marked `INVARIANT` or `CHARACTERISATION`,** and that is the point of the file rather than decoration. An invariant must hold under any model of how work is stored; a characterisation is true because of how the app stores things today. During #350, a red invariant is a bug and a red characterisation is a prompt to read it, decide whether the new model is what you meant, and update it on purpose. Without the distinction every failure looks the same, and the suite becomes noise at exactly the moment it is supposed to be useful. **Two things the app does turned out not to match how the flow reads from the outside.** The tests follow the app: - Linking a second ticket means unlinking the first. Once a ticket is linked, its card shows that ticket's pull requests and attachments and the ticket-number field is not on screen at all. Unlinking is not throwing the ticket away — it parks the work on its branch and returns the checkout to trunk — but it is a step, and a test that skipped it would exercise a path no contributor can take. - A ticket cannot be deleted while it is the linked one, because the list of a site's tickets deliberately leaves out the current one. So the `wasActive` branch in the delete handler — the one that returns the checkout to trunk and clears the link — appears to be unreachable from the interface. Left alone here; noting it because it is the kind of thing worth knowing before #350 rewrites around it. **The engine grows one thing: answering `window.confirm`.** Electron implements the JavaScript dialogs natively and blocks the renderer on them, so Playwright's `dialog` event never arrives — a test relying on it clicks "Delete this ticket's work" and watches nothing happen. Replacing `window.confirm` in the page works, and counting the calls lets a test assert that the app *asked* before it deleted anything. **Deliberately not in this PR:** applying and reverting patches, and what survives a restart. Those are the next two in the stack. ## How to test this Platforms: **any**. Windows is covered by CI, which is where the interesting half is. **Starting state:** this branch, `npm ci` done, `npm run build:once` run once. 1. `npm run test:e2e` - Expected: 8 passed in under ten seconds — the four from #362 and the four here. Electron windows open and close as it goes. 2. Watch one of them rather than trusting the count: `npx playwright test --project=journeys -g "switching back" --headed` - Expected: the app opens, a ticket is linked, another, and the panel switches back. Nothing is typed by a human. 3. Break an invariant on purpose. In `e2e/journeys/ticket-branches.spec.js`, change `expect( read( site.dir, 'wp-login.php' ) ).toBe( MY_EDIT )` to any other string, then rerun. - Expected: "switching back to a ticket restores its work byte for byte" goes red on that line. Put it back. 4. Break the substrate on purpose. In `e2e/helpers/git-site.cjs`, delete the line that writes `SUBSTRATE_CONTENT`, then rerun. - Expected: every ticket-branch journey goes red. Put it back. This is the assertion that stands in for "a switch did not silently wipe `node_modules`". 5. `npm test` and `npm run lint`. - Expected: 1027 passed, and clean. **What must not have happened:** - **Your real settings file must not have been touched** — the guard from #362 covers this, and it is worth confirming once more now that the journeys write far more state than the engine test did. - **No leftover directories.** Each journey builds a real repository under the system temp directory and removes it after the app stops. After a run, nothing matching `wpct-e2e-*` should remain. - **No Electron processes still running.** ## Risks and limitations - **Linking a ticket reaches the network, twice.** The app starts a GitHub lookup for the ticket's pull requests, and — because the ticket was linked by hand rather than restored on mount — it also auto-reads the ticket's own facts from Trac (#292). Nothing here asserts on either, and no journey waits for them, so an offline or rate-limited runner does not fail these tests. But both calls happen, and on a machine where Trac's human-check appears a window may open. An earlier version of this section said Trac is only read on request; that is true on mount and re-activation, and not true of a hand-linked ticket, which is what every journey here does. - **Selectors read the visible copy.** Renaming "Link ticket" or "Delete this ticket's work" breaks these tests. That is deliberate — it makes the copy a contract — but it is a maintenance cost, and worth saying out loud rather than discovering in a rename. - **Timing is waited for, never slept through.** Every step waits on something the app renders or on the state of the repository on disk. There is no `waitForTimeout` in the journeys, because a sleep tuned on a laptop is a flake on a Windows runner. - The row for a ticket is addressed by the ticket it names, not by position: the list is ordered by how recently each ticket was used, so `.first()` picks a different branch depending on how far the render has got. That cost a wrong-branch deletion during development, which is why it is called out in a comment in the file. ## Related Part of #359 and #361. Stacked on #362, which closes #360. --- <details> <summary>Design decisions and alternatives considered</summary> **A real repository per test, built from scratch.** These are Git operations; a stub would only prove the app can talk to a stub. The fixture mirrors the one the integration suite already uses one level down — same trunk branch, same three tracked files, same gitignored `node_modules` — so the two levels describe the same site and a failure at one level can be read against the other. **Nothing is shared between tests.** Each builds its own repository and its own profile, so no journey can be affected by the order it runs in. It costs about a second per test, which is the right trade at this size. **Assertions read the repository, not the app.** Whether a branch exists is asked of `isomorphic-git` against the directory on disk, not of the app that just claimed to have made it. An assertion that asks the app to confirm its own work is the "mocking the thing under test" shape from the review standard, one level up. **Marking assertions rather than splitting the files.** An alternative was two files per flow, invariants in one and characterisations in the other. Rejected because the reason a characterisation exists is the invariant next to it — reading "the applied patch is a record in the store" is only useful beside "the contributor's edit comes back". Splitting them would put the two halves of one argument in different files. </details> <details> <summary>Review outcome (required — see AGENTS.md)</summary> `0 [fix here] · 1 [follow-up]`. **🔵 architecture · `[follow-up]` — not this PR's to fix.** The `wasActive` branch of `branches:delete` in `src/main.js` returns the checkout to trunk and clears the link when the deleted ticket is the current one. The panel excludes the current ticket from the list it offers delete controls for, so that branch appears unreachable from the interface. Not touched here; worth confirming before #350 rewrites around it. No findings in architecture (nothing under `src/` changes), security, performance, or cross-platform. The two cross-platform traps in this diff — path comparison and deleting directories the app still holds open — were fixed in #362 and are reused here rather than re-solved. **Tests:** every invariant in this PR was verified by mutating it and confirming the test goes red. Seven mutations, seven reds: | Mutated assertion | Result | | --- | --- | | the store records the linked ticket | red | | the second ticket is the one checked out | red | | the edit comes back on switch | red | | the deletion comes back on switch | red | | delete leaves the other ticket alone | red | | delete asked for confirmation | red | | the substrate survives a switch | red | **Same caveat as #362:** this pass ran in the session that wrote the code, not in fresh context as the standard asks for. </details> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
juanmaguitar
added a commit
that referenced
this pull request
Aug 19, 2026
**Stacked on #368.** Review the ones below it first; this diff is one new journey file, plus the seed repository it needed. ## Why The other half of a contributor's day, and the half #350 changes most. Someone else's work arrives as a patch file or a pull request, goes onto the checkout, and has to come off again without taking anything of theirs with it. Today "applied" is a layer the app holds. After #350 it is a commit. So what these journeys pin is the part that has to survive either model — and the part whose failure is silent, because a patch that half-applied or a revert that ate an unrelated edit produces no error at all. ## What changes Three journeys. - **A patch applies.** The file changes on disk, the gitignored substrate and the untouched files are left alone, and the app records what it applied. - **A revert puts the checkout back**, byte for byte — and the contributor's own work in a file the patch never touched survives both directions. - **A patch that does not fit is refused.** This is the one worth having. The app warns *before* writing that the patch is about to land on the contributor's own edits, while they can still stop; then it refuses, says the checkout was not changed, names the file, and says how much of the patch failed. Nothing is written and nothing is offered to undo. **Two things about the app the fixture had wrong**, both found by running it rather than by reading: - **Patches are rewritten onto core's modern layout.** A patch naming `wp-login.php`, which is how Trac patches are written, is applied to `src/wp-login.php` — see `src/patch-plan.cjs`, which exists because core moved everything under `src/` and old patches predate it. A fixture with its files at the repository root sends every patch somewhere the test never looks, and the test then fails for a reason that is not a bug. The seed repository now mirrors a real checkout. - **Applying ends by rebuilding the site.** Without a `build` script the chain fails *after* the patch is already on disk, so the journey would be asserting on a half-finished flow. The seed repository has a `package.json` whose build does nothing and exits. **One assertion is deliberately narrow.** The conflict is asserted against the alert, not the page. When an apply fails the preview stays on screen and already names the file, so `getByText('src/wp-login.php')` passes whether or not the app reported anything — green, and proving nothing. ## How to test this Platforms: **any**. Windows is covered by CI. **Starting state:** this branch, `npm ci` and `npm run build:once` done. 1. `npm run test:e2e` - Expected: **11 passed** — four from #362, four from #363, three here. 2. Watch the interesting one: ``` E2E_VIDEO=/tmp/e2e-video npx playwright test --project=journeys -g "does not fit" open /tmp/e2e-video/a-patch-that-does-not-fit-is-refused-and-writes-nothing.webm ``` - Expected: the app opens, a ticket is linked, a patch file is chosen, the preview lists `src/wp-login.php`, a warning appears about your own edits, **Apply and rebuild** is clicked, and the refusal appears. Nothing in the file changes. 3. Break the all-or-nothing invariant. In `e2e/journeys/patch-apply.spec.js`, change `expect( read( site.dir, LOGIN ) ).toBe( mine )` to any other string, then rerun. - Expected: "a patch that does not fit is refused, and writes nothing" goes red on that line. Put it back. 4. Break the path rewrite. In `e2e/helpers/git-site.cjs`, change `LOGIN` from `src/wp-login.php` to `wp-login.php`, then rerun. - Expected: the patch journeys go red — the patch is applied where the fixture is not looking. This is the trap the fixture layout exists to avoid. Put it back. 5. `npm test` and `npm run lint`. - Expected: 1027 passed, and clean. **What must not have happened:** - **The ticket-branch journeys must still pass.** This PR moves their fixture's files under `src/`, so all four are re-exercised by step 1. If only the patch journeys were run, that move would go unchecked. - **No leftover directories.** Nothing matching `wpct-e2e-*` should remain in the system temp directory after a run. - **Your real settings file must not have been touched.** ## Risks and limitations - **Linking a ticket reads Trac.** Correcting something I wrote on #363: linking a ticket *by hand* auto-reads the ticket's own facts (#292), so these journeys do reach the network at that point, and on a machine where Trac's human-check appears a window may open. Nothing here asserts on it and no journey waits for it, so an offline or blocked runner does not fail these tests — but it happens, and #363 said it did not. - **The patches are hand-written unified diffs**, one whole-line replacement per file. That is deliberate — generating them with the same code the app reads back would prove less than it looks — but it means the shapes core actually produces (renames, binary files, empty additions) are not exercised here. Those are covered a layer down, in `test/patch-apply.integration.test.cjs`. - **The rebuild is a no-op.** The seed repository's `build` script exits immediately, so what these journeys prove about the rebuild is that the chain reaches it and survives it, not that a real build works. - **Applying a pull request is not covered**, only a patch file. Fetching a PR puts GitHub in the path of a test about the checkout. The app treats both identically once the patch text is in hand, which is the point where these journeys join the flow. ## Related Part of #359 and #361. Stacked on #368. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Nothing in the suite has ever launched the app. The Git modules are covered against real repositories and the IPC handlers against a loaded main process, but the flows a contributor actually performs — linking a ticket, applying a patch, moving between branches — are verified only by a person following a How to test this section by hand.
That matters now because of #350. The git-native refactor moves ticket branches, applied patches, conflicts and trunk updates all at once, and a characterisation suite written afterwards can only record the refactor. Written first, it says which of the old guarantees were quietly dropped — which is the failure mode here, since nearly every regression in this area is silent: work parked and never restored, a patch quietly missing a file, a ticket that forgets its base.
This PR is the engine. The journeys themselves are #361.
What changes
Two suites, because there are two different apps under test and they answer different questions.
npm run test:e2e— the journeys. Drives the app built from the source tree and writes real state. Everything a journey needs before it can assert anything lives in one session helper, so no spec has to remember it:close()is raced against a timeout and the process killed after it — closing is known to hang on Windows in apps that keep child processes alive, which this one does constantly.npm run test:e2e:packaged— the packaged smoke test. Launches an unsigned--dirbuild and asks only whether packaging worked. This is the test from the now-closed #70, rebuilt rather than rebased — see below.Both run on macOS and Windows for every non-draft pull request, as two separate jobs so a five-second suite is not waiting behind a fifteen-minute one. Neither downloads a browser: the only thing launched is the Electron already in the tree, so there is no
playwright installstep anywhere.Deliberately not in this PR: any journey.
e2e/journeys/engine.spec.jsasserts nothing about ticket branches or patches — it asserts that the four things every journey will depend on actually work, so that when a journey fails it is about the flow and not about the harness.How to test this
Platforms: any for the journeys. The packaged half is macOS or Windows only, and the point of the CI matrix is that Windows is the half nobody can check locally.
Starting state: a clean checkout of this branch,
npm cidone. Note the modification time of your real settings file before you start — on macOS,~/Library/Application Support/electron-setup-wordpress-core/settings.json.npm run build:once && npm run test:e2enpm testtrunk. The unit runner only collectstest/, so it must not pick up anything added here.npm run lintCSC_IDENTITY_AUTO_DISCOVERY=false npm run pack:dir && npm run test:e2e:packagede2e/helpers/app.cjs, changeTOOLKIT_USER_DATA_DIR: this.userDataDirtoTOOLKIT_USER_DATA_DIR: undefined, thennpm run test:e2e.e2e/packaged/smoke.spec.js, delete any one entry fromEXPECTED_API_KEYS, thennpm run test:e2e:packaged.What must not have happened:
npm testmust not have grown. If the count moved off 1027, the unit runner started collecting end-to-end specs, andnpm teststops being something you run without thinking.ps aux | grep -i electronon macOS, Task Manager on Windows.Risks and limitations
kill()rather thansrc/kill-tree.js. It only runs afterclose()has already had its timeout, and it is test code, but if orphaned Electron processes ever show up on the runners, that is where to look.Related
Part of #359. Closes #360. Closes #67 — its three assertions ship here as the
packagedproject, on both platforms, documented inCONTRIBUTING.md, with Buildkite untouched. Replaces #70, which is closed. Unblocks #361, and #350 after it.Design decisions and alternatives considered
@playwright/testrather thannode --testwithplaywright-core. The first draft went the other way, to keep one runner in the repo and add no dependency. Requiring the suite to run in CI on two platforms for every pull request inverted that: retries, per-test timeouts, traces, the HTML report and the reporting artifacts are all things I would otherwise have written and then maintained, badly. Two runners is not a defect here —npm teststays the fast offline suite andnpm run test:e2eis the one that launches an app, and they are meant to be told apart.The dependency cost turned out to be smaller than assumed:
@playwright/testat this version has no install script and downloads no browser. I checked the published tarball rather than trusting the recollection — an older version of Playwright did download browsers on install, which is whyscripts/screenshots/capture.cjsdepends onplaywright-coredirectly and says so in its header.A session object rather than a
pagefixture. A journey has to build its Git fixture before the app opens, because the site list has to name directories that already exist, and it needs the profile path to write that list. A fixture that launches the app for you takes that away. So the profile directory exists from construction and the spec decides when to start.Rebuilt #70 rather than rebasing it. Two of its three assertions had gone stale in ways that would fail on first run: the list of bridged keys had roughly half the entries the app exposes today, and the native module it probes is no longer optional and no longer absent on Windows — it is a hard dependency of the bundled PHP runtime with a Windows-specific file-lock path — so its documented Windows exclusion had turned from a decision into a hole. Its structure, its comments and its reasoning are otherwise carried over almost verbatim; the closing comment on #70 lists what came across.
Not adding
data-testidanywhere. The selectors use the text and roles already on screen, which also pins the visible copy as a contract. Where a name appears twice — the sidebar entry and the heading of the open site — roles tell them apart, and the assertion says which half of the app it is about.Review outcome (required — see AGENTS.md)
2 [fix here] · 2 [follow-up]— both[fix here]fixed before this was pushed.🔴 cross-platform ·
[fix here]— fixed. The launch guard comparedpath.resolve(a) !== path.resolve(b). That would have aborted every test on Windows, and possibly on macOS, for reasons unrelated to what it guards: macOS returns/var/folders/…fromos.tmpdir()and/private/var/…from the app, since the first is a symlink to the second, and Windows returns shortRUNNER~1names in some environments and long ones in others, on a case-insensitive filesystem. Replaced with asamePath()that resolves throughrealpathand lowercases off POSIX.🔴 cross-platform ·
[fix here]— fixed. The fake site directories were removed in anafterEach, which Playwright runs before fixture teardown — with the app still alive and holding handles on them. On Windows that isEPERM, and a failure that is not the test's. Cleanup moved onto the session, which removes them afterclose().🟡 tests ·
[follow-up]. The teardown's last-resortkill()is notsrc/kill-tree.js. Noted in Risks above.🔵 performance ·
[follow-up]. The packaging job's cost per pull request. Noted in Risks above.No findings in architecture (nothing under
src/changes), security (the file-dialog stub is installed at runtime from the test, so nothing test-shaped ships in the app), or the new-dependency rule (devDependency, no install script — verified against the published tarball — no native compilation, so noallowScriptsentry is needed and the zero-prerequisite promise is untouched).One caveat. The standard asks for this pass to run in fresh context rather than in the session that wrote the code. It did not: the session that wrote this reviewed it. Worth a second pass by someone who has not already decided it is correct.
Implementation notes
Two things surfaced only by running this against the current tree, neither of which #70 could have seen:
process.mainModule.filenameis not stable in Electron. The module-resolution assertions anchored acreateRequirethere. Partway through a run it reads back as the bare string'electron', so the first module checked failed and the same test passed when run alone — a packaging test going red for a reason that has nothing to do with packaging. Now anchored atjoin(app.getAppPath(), 'package.json'), which is deterministic and is inside the asar either way.Two bridged keys cannot be found by reading
src/preload.js.signInToGithubandcancelGithubSignInare spread in from an immediately invoked function that closes over a listener, so they exist only once the file has run. Any attempt to derive the expected-keys list by grepping the object literal misses them — which is the same class of gap the assertion itself exists to close, one level up. There is a comment in the file saying so, because the next person to regenerate that list will otherwise reintroduce the same two-key error.The
react-hooks/rules-of-hooksoverride fore2e/is not a suppression of a real problem: Playwright's fixture API isasync ({ deps }, use) => {}, and the rule reads a bareuse(...)as React'susehook outside a component. There is no React in that directory; the app under test is a separate process.