Skip to content

Changed the E2E isolation model defaults#26761

Merged
9larsons merged 11 commits intomainfrom
ber-3428-rework-e2e-isolation-model
Mar 18, 2026
Merged

Changed the E2E isolation model defaults#26761
9larsons merged 11 commits intomainfrom
ber-3428-rework-e2e-isolation-model

Conversation

@jonatansberg
Copy link
Copy Markdown
Member

@jonatansberg jonatansberg commented Mar 10, 2026

Summary

  • default E2E isolation to per-file and add a root-level test.describe.configure({mode: 'parallel'}) opt-in for per-test isolation
  • add fixture mode resolution, mode guard rails, resolvedIsolation, and resetEnvironment() to support mixed isolation safely
  • support both local workflows by auto-selecting build vs dev mode and opt stateful member/billing suites back into per-test isolation

Performance

  • Over the last 3 days, Velo reports Ghost CI averaging 29.7 minutes per successful run. The latest successful run on this branch completed in 22.6 minutes, a 7.1 minute improvement (23.8%).

  • Looking specifically at the E2E matrix, the last 10 successful CI runs averaged a 17.8 minute critical path across the 4 E2E shards; this branch completed that path in 13.1 minutes, a 4.7 minute improvement (26.2%).

  • Total E2E shard compute time also dropped from 66.9 minutes on average to 45.6 minutes, reducing runner time by 21.3 minutes (31.9%).

ref https://linear.app/ghost/issue/BER-3428/rework-e2e-isolation-model

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 10, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fbf606f9-1d56-454f-b0a7-5f2f403b7b5c

📥 Commits

Reviewing files that changed from the base of the PR and between c292076 and 23756ae.

📒 Files selected for processing (3)
  • e2e/.claude/E2E_TEST_WRITING_GUIDE.md
  • e2e/AGENTS.md
  • e2e/scripts/resolve-e2e-mode.sh
🚧 Files skipped from review as they are similar to previous changes (2)
  • e2e/scripts/resolve-e2e-mode.sh
  • e2e/.claude/E2E_TEST_WRITING_GUIDE.md

Walkthrough

This pull request introduces a refined test isolation model for end-to-end tests. The changes encompass an updated Playwright fixture system that supports both per-file and per-test isolation modes, with environment caching and reuse capabilities. Configuration scripts for environment mode resolution are added alongside ESLint enforcement rules for safe environment reset patterns. Documentation is expanded to clarify isolation behavior, environment identity semantics, and lifecycle management. Several test files are updated to adopt the new per-test isolation configuration, and Playwright configuration timeouts are adjusted.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Changed the E2E isolation model defaults' directly and clearly summarizes the main change: shifting E2E test isolation from per-test to per-file as the default.
Description check ✅ Passed The description clearly relates to the changeset by outlining the key modifications (per-file default isolation, per-test opt-in, fixture mode resolution, auto-selection of build vs dev modes) and supporting these with measurable performance metrics.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ber-3428-rework-e2e-isolation-model
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ErisDS
Copy link
Copy Markdown
Member

ErisDS commented Mar 10, 2026

🤖 Velo CI Failure Analysis

Classification: 🟠 SOFT FAIL

  • Workflow: CI
  • Failed Step: Set up Docker Buildx
  • Run: View failed run
    What failed: CI failure - likely code issue
    Why: The failure appears to be related to code changes. Check the error output for details.
    Action:
    Review the error logs and fix the issue in your code.

@9larsons
Copy link
Copy Markdown
Contributor

Planning to look at this tomorrow morning!

@9larsons
Copy link
Copy Markdown
Contributor

Ok, I pushed some changes - we can revert if desired, it just felt easier to push them in for now.

The main gripe was the monkey patching. Even if valid, it felt fragile and, well, icky. Afaict the main reason to do this was to avoid multiple commands for the test-writer to avoid needing to call test.use({isolation: 'per-test'}); along with mode: parallel. I created a helper for this.

The other function of monkey patching was avoiding the deprecated test.describe.parallel and test.describe.serial so I've created an ESLint rule to capture those instead.

Overall, I'd still like to maintain full isolation, but agree it's:

  1. A performance gain we sorely need atm
  2. Not terribly difficult to reason about per-test-file state (retries can cause problems here, sometimes)

I think the next point of interest would be not needing to fully restart the Ghost container after db reset, which might buy us back some of the gap here with many restarts per file, or just be additional gains.

Copy link
Copy Markdown
Contributor

@9larsons 9larsons left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved as long as you're ok with the changes I pushed - just re-request review if you'd like to discuss more.

jonatansberg and others added 5 commits March 18, 2026 10:25
ref https://linear.app/ghost/issue/BER-3428/rework-e2e-isolation-model
Defaulted E2E isolation to per-file, added root-level per-test opt-in, and updated the fixture and runner behavior to support mixed local workflows.
ref https://linear.app/ghost/issue/BER-3428/rework-e2e-isolation-model
Moved mode sniffing into a shared shell helper and removed the conflicting default-dev assumptions so the E2E entrypoints resolve dev vs build consistently.
ref https://linear.app/ghost/issue/BER-3428/rework-e2e-isolation-model
The default GitHub-hosted runner shape leaves limited CPU headroom for E2E, so keeping worker count at 1 and tightening timeouts gives us faster failure signal without increasing runner contention.
ref https://linear.app/ghost/issue/BER-3428/rework-e2e-isolation-model
Removed ~120 lines of monkey-patching (stack-trace parsing, describe
wrapping, runtime mode guards) in favour of a single helper that
composes two standard Playwright APIs: test.describe.configure() and
test.use(). The fixture now reads an explicit `isolation` option
instead of intercepting configure() calls via a side-channel registry.
ref https://linear.app/ghost/issue/BER-3428/rework-e2e-isolation-model
Catches test.describe.parallel() and test.describe.serial() at lint
time with actionable error messages pointing to the correct replacement.
This replaces the runtime monkey-patching that was removed in the
previous commit.
@9larsons 9larsons force-pushed the ber-3428-rework-e2e-isolation-model branch from b712c1b to 7ff0af7 Compare March 18, 2026 15:29
@9larsons 9larsons marked this pull request as ready for review March 18, 2026 15:35
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
e2e/eslint.config.js (1)

109-116: Add ESLint rule to forbid direct test.describe.configure({mode: 'parallel'}) calls.

Lines 109-116 restrict the deprecated sugar syntax but miss the supported Playwright API. Direct test.describe.configure({mode: 'parallel'}) bypasses the lint rules, allowing concurrent tests to share the per-file environment. Verification confirms no current direct uses exist in e2e/tests, but the gap should be closed to prevent future issues.

Add a rule to catch MemberExpression[object.name="test"][property.name="describe"][property.callee] patterns with {mode: 'parallel'} in the argument object.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/eslint.config.js` around lines 109 - 116, Add an ESLint rule in
e2e/eslint.config.js to forbid direct calls to test.describe.configure({mode:
'parallel'}) by matching the AST CallExpression where the callee is the
MemberExpression test.describe.configure and the first argument is an
ObjectExpression with a property mode whose value is 'parallel'; emit a message
instructing to use usePerTestIsolation() from `@/helpers/playwright/isolation`
instead. Target the existing rule array near the test.describe.parallel/serial
entries and add a rule that references the call form (test.describe.configure)
and the offending object argument to prevent bypassing the per-test isolation
linting.
e2e/scripts/resolve-e2e-mode.sh (1)

12-14: Consider slightly relaxing the dev-server probe timeout.

curl --max-time 1 can mis-detect a warming local dev server and unexpectedly fall back to build. A small increase (or split connect/read timeouts) would make auto-detection less brittle.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/scripts/resolve-e2e-mode.sh` around lines 12 - 14, The dev-server probe
in resolve-e2e-mode.sh uses curl with --max-time 1 which is too short; update
the curl invocation that checks "$LOCAL_ADMIN_DEV_SERVER_URL" to relax the
timeout (for example increase --max-time to 2 or use separate timeouts like
--connect-timeout 1 --max-time 3) so the probe is less likely to mis-detect a
warming server; ensure the modified curl still uses --silent --fail and
redirects output to /dev/null as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@e2e/.claude/E2E_TEST_WRITING_GUIDE.md`:
- Around line 223-242: The docs incorrectly imply resolvedIsolation and
resetEnvironment() are properties of ghostInstance; instead state they are
standalone fixtures exported from fixture.ts and used like any other fixture
(e.g., resolvedIsolation, resetEnvironment). Update the example and surrounding
text to show resolvedIsolation and resetEnvironment() being injected as separate
parameters, note that resetEnvironment() should be called as the final step in a
test unless the test re-establishes auth/page state, and reconcile the “every
test gets a fresh Ghost instance” wording by clarifying that per-file mode
preserves instance across tests while usePerTestIsolation() (imported from
`@/helpers/playwright/isolation`) switches to per-test isolation which forces new
Ghost instances for each test.

In `@e2e/helpers/environment/service-managers/ghost-manager.ts`:
- Line 287: The dev-mode container override currently sets Cmd to ['node',
'--import=tsx', 'index.js'] which bypasses the image's yarn dev entrypoint and
hot-reload tooling; modify the logic around Cmd (the line setting Cmd when mode
=== 'dev') to leave Cmd unset (or remove the override) for dev mode so the
Dockerfile default CMD ["yarn","dev"] is used, ensuring nodemon/hot-reload is
preserved in the e2e dev workflow.

In `@e2e/helpers/playwright/fixture.ts`:
- Around line 76-78: getConfigSignature currently ignores the `labs` test option
so per-file environment reuse (the logic that compares configSignature to decide
sharing) can leak lab flags; update getConfigSignature to include the `labs`
field from the incoming GhostConfig (e.g., serialize { ...config, labs:
config?.labs ?? null } or explicitly include config.labs) so the returned
signature reflects lab overrides and prevents reuse when labs differ; ensure the
same signature is used where configSignature is computed so the per-file cache
key includes labs.

In `@e2e/scripts/resolve-e2e-mode.sh`:
- Around line 7-10: The script resolve-e2e-mode.sh currently returns any
non-empty GHOST_E2E_MODE; change it to validate that GHOST_E2E_MODE is one of
the allowed modes (e.g., "ci", "local", "staging" — use the actual valid set
your project expects) before printing and returning it; if the value is invalid,
print a clear error and exit with a non-zero status (or fall back to a safe
default) so downstream consumers of the resolved mode (the logic around the
printf in resolve-e2e-mode.sh) cannot receive typos like "buid".

---

Nitpick comments:
In `@e2e/eslint.config.js`:
- Around line 109-116: Add an ESLint rule in e2e/eslint.config.js to forbid
direct calls to test.describe.configure({mode: 'parallel'}) by matching the AST
CallExpression where the callee is the MemberExpression test.describe.configure
and the first argument is an ObjectExpression with a property mode whose value
is 'parallel'; emit a message instructing to use usePerTestIsolation() from
`@/helpers/playwright/isolation` instead. Target the existing rule array near the
test.describe.parallel/serial entries and add a rule that references the call
form (test.describe.configure) and the offending object argument to prevent
bypassing the per-test isolation linting.

In `@e2e/scripts/resolve-e2e-mode.sh`:
- Around line 12-14: The dev-server probe in resolve-e2e-mode.sh uses curl with
--max-time 1 which is too short; update the curl invocation that checks
"$LOCAL_ADMIN_DEV_SERVER_URL" to relax the timeout (for example increase
--max-time to 2 or use separate timeouts like --connect-timeout 1 --max-time 3)
so the probe is less likely to mis-detect a warming server; ensure the modified
curl still uses --silent --fail and redirects output to /dev/null as before.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 76d6b8dc-7ecc-45d3-8e47-e6356bf65856

📥 Commits

Reviewing files that changed from the base of the PR and between 5ff9a37 and 7ff0af7.

📒 Files selected for processing (16)
  • e2e/.claude/E2E_TEST_WRITING_GUIDE.md
  • e2e/AGENTS.md
  • e2e/README.md
  • e2e/eslint.config.js
  • e2e/helpers/environment/environment-manager.ts
  • e2e/helpers/environment/service-managers/ghost-manager.ts
  • e2e/helpers/playwright/fixture.ts
  • e2e/helpers/playwright/isolation.ts
  • e2e/playwright.config.mjs
  • e2e/scripts/infra-up.sh
  • e2e/scripts/resolve-e2e-mode.sh
  • e2e/scripts/run-playwright-host.sh
  • e2e/tests/admin/billing/force-upgrade.test.ts
  • e2e/tests/admin/members/export.test.ts
  • e2e/tests/admin/members/filter-actions.test.ts
  • e2e/tests/admin/members/members.test.ts

@9larsons 9larsons self-assigned this Mar 18, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
e2e/eslint.config.js (1)

7-7: Consider including ghostInstance in the stale fixtures list.

Based on context snippet 1 (fixture.ts:392-417), ghostInstance is also affected by cycle() since the holder object is mutated via Object.assign(holder, nextInstance). While the current fixtures (baseURL, ghostAccountOwner, page, pageWithAuthenticatedUser) cover the most common cases, ghostInstance itself would also become stale after a recycle if directly destructured alongside resetEnvironment.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/eslint.config.js` at line 7, The resetEnvironmentStaleFixtures array
currently lists
['baseURL','ghostAccountOwner','page','pageWithAuthenticatedUser'] but omits
ghostInstance, which becomes stale after cycle() mutates the holder via
Object.assign(holder, nextInstance); add 'ghostInstance' to
resetEnvironmentStaleFixtures so the fixture cleanup includes it and prevents
stale destructured values when resetEnvironment is used.
e2e/helpers/playwright/fixture.ts (1)

155-162: Consider handling session expiry gracefully.

If the cached storageState is stale (e.g., session expired), neither the analytics header nor billing iframe may appear, causing the test to hang until Playwright's timeout kicks in. While the timeout will eventually fail the test, a more descriptive error could help debugging.

♻️ Optional: Add a timeout with a clearer error message
-    await Promise.race([
-        analyticsPage.header.waitFor({state: 'visible'}),
-        billingIframe.waitFor({state: 'visible'})
-    ]);
+    const loaded = await Promise.race([
+        analyticsPage.header.waitFor({state: 'visible'}).then(() => true),
+        billingIframe.waitFor({state: 'visible'}).then(() => true)
+    ]).catch(() => false);
+
+    if (!loaded) {
+        throw new Error('Cached storage state may be stale: unable to load authenticated page');
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/helpers/playwright/fixture.ts` around lines 155 - 162, The Promise.race
waiting for analyticsPage.header or billingIframe can hang if cached
storageState is expired; update the wait to include an explicit timeout promise
that rejects with a clear error (e.g., "session expired or storageState stale:
neither AnalyticsOverviewPage.header nor Billing iframe became visible") so
failures are descriptive; modify the block around page.goto('/ghost/#/'),
AnalyticsOverviewPage(page) and billingIframe.waitFor to race those waits
against a timed reject and optionally check for redirect to sign-in before
throwing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@e2e/AGENTS.md`:
- Line 128: The list item containing "networkidle in waits** (`networkidle`)"
has stray asterisks breaking markdown formatting; edit that line in AGENTS.md
(the text "networkidle in waits** (`networkidle`)") to remove the extra
asterisks so it reads e.g. "networkidle in waits (`networkidle`)" or otherwise
valid markdown list syntax, preserving the inline code token.

---

Nitpick comments:
In `@e2e/eslint.config.js`:
- Line 7: The resetEnvironmentStaleFixtures array currently lists
['baseURL','ghostAccountOwner','page','pageWithAuthenticatedUser'] but omits
ghostInstance, which becomes stale after cycle() mutates the holder via
Object.assign(holder, nextInstance); add 'ghostInstance' to
resetEnvironmentStaleFixtures so the fixture cleanup includes it and prevents
stale destructured values when resetEnvironment is used.

In `@e2e/helpers/playwright/fixture.ts`:
- Around line 155-162: The Promise.race waiting for analyticsPage.header or
billingIframe can hang if cached storageState is expired; update the wait to
include an explicit timeout promise that rejects with a clear error (e.g.,
"session expired or storageState stale: neither AnalyticsOverviewPage.header nor
Billing iframe became visible") so failures are descriptive; modify the block
around page.goto('/ghost/#/'), AnalyticsOverviewPage(page) and
billingIframe.waitFor to race those waits against a timed reject and optionally
check for redirect to sign-in before throwing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c6140bfa-93de-4009-af59-dd17021b8974

📥 Commits

Reviewing files that changed from the base of the PR and between 7ff0af7 and c292076.

📒 Files selected for processing (4)
  • e2e/AGENTS.md
  • e2e/README.md
  • e2e/eslint.config.js
  • e2e/helpers/playwright/fixture.ts

@9larsons 9larsons enabled auto-merge (squash) March 18, 2026 19:05
@9larsons 9larsons merged commit 604a364 into main Mar 18, 2026
28 checks passed
@9larsons 9larsons deleted the ber-3428-rework-e2e-isolation-model branch March 18, 2026 19:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants