Skip to content

fix(server): use --env-file-if-exists in dev script - #2522

Merged
Skords-01 merged 1 commit into
mainfrom
devin/1778686044-server-env-file-if-exists
May 13, 2026
Merged

Skords-01 merged 1 commit into
mainfrom
devin/1778686044-server-env-file-if-exists

Conversation

@Skords-01

@Skords-01 Skords-01 commented May 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

The apps/server dev script used node --env-file=.env, which hard-fails when .env is missing. In the extended-e2e nightly workflow the server is launched in the background with env vars injected via the workflow env: block and no .env on disk — so the server exits immediately with status 9 (node: .env: not found), the Wait for servers step times out at 60s, and all three browser matrix entries fail.

That is the single deterministic root cause behind every open flaky-extended-e2e issue from at least 2026-05-12 onward (14 open issues as of 2026-05-13 — newest #2510, run https://github.com/Skords-01/Sergeant/actions/runs/25780918948).

Fix: switch to --env-file-if-exists=.env (Node 20.12+). Locally a developer's .env is still loaded; in CI the missing file is ignored (warning to stderr, exit 0).

Evidence from chromium leg of run 25780918948 ("Start API server" step):

> @sergeant/server@0.1.0 dev
> node --env-file=.env --import tsx src/index.js

node: .env: not found
 ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL  @sergeant/server@0.1.0 dev
Exit status 9

The companion CI fix (de-dup notify-failure so a single rolling issue is updated instead of 14+ duplicates) lands in a separate PR. After this lands and one nightly succeeds, I'll consolidate the duplicates.

Governing Skill

  • Primary skill: sergeant-server-api (touches apps/server).
  • Secondary skill (if truly needed): n/a

Playbook

  • Primary playbook: docs/playbooks/fix-failing-ci.md (extended-e2e nightly was red 14 nights in a row).
  • Why this playbook: deterministic CI red — root cause is server boot regression, not test flake.
  • If no playbook matched, why: n/a

Verification

# 1. Confirm the new flag works on the repo's Node version (20.20.2 per .nvmrc).
node -e 'process.exit(0)' --env-file-if-exists=/nonexistent
# → exit 0, stderr: "/nonexistent not found. Continuing without it."

# 2. Prettier on the changed manifest.
pnpm exec prettier --check apps/server/package.json
# → "All matched files use Prettier code style!"

# 3. Husky pre-commit pipeline ran on commit:
#    - prettier --write on apps/server/package.json (no changes)
#    - lint-staged completed cleanly

Additional checks:

  • Local smoke / manual validation completed (--env-file-if-exists semantics verified on Node 20.20.2)
  • Surface-specific checks completed (prettier + husky pre-commit)

Docs and Governance

  • I updated docs that changed with the behavior, contract, workflow, or rollout. — n/a, script-only change.
  • I checked whether AGENTS.md needed an update. — no rule change.
  • I checked whether a playbook or skill needed an update. — no, dev-script contract unchanged from a developer's perspective (still loads .env if present).
  • I checked whether governance docs or review docs needed an update. — no.

Updated docs:

  • n/a

Risk and Rollout

  • User-visible risk: none for local dev — when .env exists (the documented setup per CONTRIBUTING.md § Setup), behaviour is identical. The only observable change is that pnpm dev:server no longer crashes when .env is absent; it falls back to whatever is in process.env.
  • Rollout / deploy order: merge → next nightly extended-e2e run should boot the API and proceed past Wait for servers. Production start script (node dist-server/index.js) is untouched.
  • Backout plan: revert this one-line change; nothing else depends on the flag rename.

Hard Rule #15

  • I read AGENTS.md before coding.
  • Internal docs I touched are in Ukrainian. — n/a, no docs touched.
  • I did not use --no-verify.

Audit-freeze (until 2026-06-02)

  • This PR does not add new top-level audit/initiative/playbook/ADR files (or override is justified below).

Reviewer Notes

  • Single one-line change in apps/server/package.json. The start, start:replit, and migration scripts intentionally are not changed — they don't reference --env-file at all and run from production / migration paths where env is injected differently.
  • The other open flaky-extended-e2e issues from 2026-05-10/05-11 had a different signature (webkit / mobile-safari failing inside the @auth test grep, not at Wait for servers). Those are likely real Safari/ITP flakes, tracked separately — they are not addressed here.

Summary by cubic

Switch apps/server dev script to node --env-file-if-exists=.env so a missing .env doesn’t crash the server. Local .env still loads; CI without the file now runs, unblocking extended-e2e.

  • Bug Fixes
    • Prevents server exit when .env is absent in CI, avoiding the “Wait for servers” timeout.
    • Only the dev script changed; production start is unchanged.

Written for commit 8618467. Summary will update on new commits.

Summary by CodeRabbit

  • Chores
    • Updated the development script configuration to make environment file optional. The dev environment now runs successfully even when the .env file is not present.

Review Change Stack

The 'dev' script used `node --env-file=.env`, which hard-fails when
.env is missing. In the extended-e2e nightly workflow the server is
launched as a background process with env vars injected via the
workflow `env:` block and no .env file on disk, so the server exits
immediately with status 9 ("node: .env: not found"). The subsequent
`Wait for servers` step then times out at 60s, taking down all
three browser matrix entries and auto-filing a fresh issue every
night via the `notify-failure` job (14 open issues with label
`flaky-extended-e2e` as of 2026-05-13).

Switching to `--env-file-if-exists=.env` (Node 20.12+) makes the
file optional: local dev still loads .env, CI runs without it.
No behaviour change when .env is present.

Co-Authored-By: dmytro.s.stakhov <dmytro.s.stakhov@gmail.com>
@vercel

vercel Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
sergeant Ignored Ignored May 13, 2026 3:29pm

Request Review

@coderabbitai

coderabbitai Bot commented May 13, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The dev script in the server package was updated to use the --env-file-if-exists flag, making the .env file optional during development rather than required. The server can now start without the file present.

Changes

Dev Script Configuration

Layer / File(s) Summary
Dev script environment file flag
apps/server/package.json
The dev npm script now uses --env-file-if-exists=.env instead of --env-file=.env, allowing the development server to start when the .env file is missing.

🎯 1 (Trivial) | ⏱️ ~2 minutes

A gentle change, so kind and fair, ✨
The .env file—optional, light as air,
Dev mode hops along without a care,
No crash when the file isn't there,
A rabbit's blessing, beyond compare! 🐰

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: updating the dev script to use --env-file-if-exists instead of --env-file, which is the core modification in this PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch devin/1778686044-server-env-file-if-exists

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/server/package.json`:
- Line 7: The package.json currently sets the "dev" script to use the
--env-file-if-exists flag which requires Node >=20.19.0 while engines.node is
"20.x"; update package.json so engines.node is tightened to ">=20.19.0 <21" (or
equivalent semver) to enforce the required minimum, or alternatively modify the
"dev" script to provide a backward-compatible fallback (e.g., use a
dotenv/env-cmd loader when Node <20.19.0) so running the "dev" script doesn't
fail on older 20.x minors; refer to the "dev" script and the engines.node field
when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c75f1462-ddb7-4ff1-b7fe-1a40133e6990

📥 Commits

Reviewing files that changed from the base of the PR and between 024ab97 and 8618467.

📒 Files selected for processing (1)
  • apps/server/package.json

Comment thread apps/server/package.json
"type": "module",
"scripts": {
"dev": "node --env-file=.env --import tsx src/index.js",
"dev": "node --env-file-if-exists=.env --import tsx src/index.js",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In official Node.js CLI docs/changelog, which exact Node 20 minor introduced --env-file-if-exists, and is it available in Node 20.0.0?

💡 Result:

The --env-file-if-exists flag was introduced in Node.js 20.19.0 via backport PR #56932, merged on 2025-03-06.[1][2] Official CLI docs confirm it was added in v22.9.0 (mainline) but backported as experimental to v20.19.0.[3] The commits b8c89a693e (src) and 9fda8e29cd (doc marking experimental) appear in the v20.19.0 release.[2] It is not available in Node 20.0.0, as --env-file itself was added in 20.6.0[4] and this flag came over a year later via semver-minor backport to LTS.[1]

Citations:


Tighten engines.node minimum to 20.19.0 or add a fallback.

The --env-file-if-exists flag requires Node.js 20.19.0 or higher, but engines.node: "20.x" allows Node 20.0.0–20.18.x. Developers on older Node 20 minors will fail when running the dev script. Update the engine constraint to >=20.19.0 <21 or provide a compatible fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/package.json` at line 7, The package.json currently sets the
"dev" script to use the --env-file-if-exists flag which requires Node >=20.19.0
while engines.node is "20.x"; update package.json so engines.node is tightened
to ">=20.19.0 <21" (or equivalent semver) to enforce the required minimum, or
alternatively modify the "dev" script to provide a backward-compatible fallback
(e.g., use a dotenv/env-cmd loader when Node <20.19.0) so running the "dev"
script doesn't fail on older 20.x minors; refer to the "dev" script and the
engines.node field when making the change.

@github-actions

Copy link
Copy Markdown
Contributor

⏱️ CI Pipeline Duration Report

Based on the last 50 successful runs on the default branch.

Overall Pipeline

Metric Value
p50 6m 26s
p95 7m 55s
p99 9m 3s
Current run 8m 55s
vs p95 +12.6%

Trend (last 20 runs): ▃▃▁▂▃▃▃▂▃▃▂▂▄▃▃▆▅▄█▆

Per-Job Breakdown

Job p50 p95 p99 Current vs p95
Accessibility (axe-core) 2m 5s 2m 21s 2m 23s 0s -100.0%
Commit messages (commitlint) 0s 0s 0s 35s N/A
Critical-flow E2E (Playwright) 1m 36s 1m 44s 1m 44s 8m 29s +389.4%
Migration lint (AGENTS rule 0s 0s 0s 10s N/A
Pipeline duration (p95 trend) 26s 27s 27s — —
Secret scan (gitleaks) 8s 11s 11s 9s -18.2%
Smoke E2E (Playwright) 1m 26s 1m 40s 1m 40s — —
Test coverage (vitest) 2m 4s 2m 33s 2m 33s 4m 15s +66.7%
Workflow lint (actionlint) 7s 7s 7s 9s +28.6%
check 4m 12s 4m 54s 5m 6s 1m 0s -79.6%
tsconfig strict guard (PR-1.A) 5s 14s 14s 6s -57.1%

Skords-01 added a commit that referenced this pull request May 13, 2026
Re-#2510 — extended-e2e nightly was failing at `Wait for servers` because
the server's `dev` script hard-failed on a missing .env. That root cause
is fixed in #2522. Re-running the suite via workflow_dispatch (run
#25821506416) surfaced the next failure mode in the same test:

  `@extended offline: shows OfflineBanner status`

The chromium screenshot was a fully blank page and the trace showed:

  Failed to load resource: net::ERR_INTERNET_DISCONNECTED
    http://127.0.0.1:4173/assets/vendor-web-vitals-Dr8xtVUp.js
    http://127.0.0.1:4173/assets/dm-sans-latin-{wght,ext-wght}-…woff2

Root cause is a navigation/timing race:

1. Playwright resolves `page.goto('/', { waitUntil: 'domcontentloaded' })`
   the moment HTML parsing finishes.
2. We immediately call `context.setOffline(true)`.
3. `apps/web/src/main.tsx` schedules a deferred init via
   `requestIdleCallback(scheduleInit, { timeout: 2000 })` which dynamically
   imports `web-vitals` — that chunk request now resolves with
   `net::ERR_INTERNET_DISCONNECTED`.
4. The font subsets that CSS resolution is waiting on also fail with the
   same error, so React's first paint never happens. `#root` stays empty
   and `OfflineBanner` is never mounted.

The test then waits 10 s for a banner that has no React tree to live in,
and fails.

The fix is on the test side, not the app side: the production app
behaves correctly here — when the user actually loses the network
mid-session, web-vitals has long since been imported. The test was just
flipping the network before the app finished initial bootstrapping.

Two changes to make the test deterministic:

- `waitUntil: 'domcontentloaded'` → `waitUntil: 'load'`, so all initial
  chunks (including the deferred vendor-web-vitals chunk Vite preloads
  via `<link rel='modulepreload'>`) finish before we drop the network.
- `await expect(page.locator('#root > *').first()).toBeAttached();` —
  proof that React's first commit landed, so the OfflineBanner is in
  the DOM and the only thing left under test is its reaction to the
  `offline` event (which is what the assertion actually wants to cover).

This brings the chromium @Extended lane of the nightly extended-e2e
workflow back to green. The webkit / mobile-safari @auth lanes have a
pre-existing, separately-tracked flake in
`tests/smoke/auth-webkit.spec.ts` (Better Auth + Safari ITP cookie
regression — see PR-48); that is out of scope for this PR.

Verification:

- `pnpm --filter @sergeant/web exec prettier --check
  apps/web/tests/smoke/navigation-offline-sw.spec.ts` — passes
- `pnpm --filter @sergeant/web exec eslint
  tests/smoke/navigation-offline-sw.spec.ts` — 0 errors

The actual E2E run is triggered via workflow_dispatch on this branch
once it's pushed; the chromium lane should go green for the first time
since #2510 was filed.

Co-Authored-By: dmytro.s.stakhov <dmytro.s.stakhov@gmail.com>
Skords-01 added a commit that referenced this pull request May 13, 2026
Re-#2510 — extended-e2e nightly was failing at `Wait for servers` because
the server's `dev` script hard-failed on a missing .env. That root cause
is fixed in #2522. Re-running the suite via workflow_dispatch (run
#25821506416) surfaced the next failure mode in the same test:

  `@extended offline: shows OfflineBanner status`

The chromium screenshot was a fully blank page and the trace showed:

  Failed to load resource: net::ERR_INTERNET_DISCONNECTED
    http://127.0.0.1:4173/assets/vendor-web-vitals-Dr8xtVUp.js
    http://127.0.0.1:4173/assets/dm-sans-latin-{wght,ext-wght}-…woff2

Root cause is a navigation/timing race:

1. Playwright resolves `page.goto('/', { waitUntil: 'domcontentloaded' })`
   the moment HTML parsing finishes.
2. We immediately call `context.setOffline(true)`.
3. `apps/web/src/main.tsx` schedules a deferred init via
   `requestIdleCallback(scheduleInit, { timeout: 2000 })` which dynamically
   imports `web-vitals` — that chunk request now resolves with
   `net::ERR_INTERNET_DISCONNECTED`.
4. The font subsets that CSS resolution is waiting on also fail with the
   same error, so React's first paint never happens. `#root` stays empty
   and `OfflineBanner` is never mounted.

The test then waits 10 s for a banner that has no React tree to live in,
and fails.

The fix is on the test side, not the app side: the production app
behaves correctly here — when the user actually loses the network
mid-session, web-vitals has long since been imported. The test was just
flipping the network before the app finished initial bootstrapping.

Two changes to make the test deterministic:

- `waitUntil: 'domcontentloaded'` → `waitUntil: 'load'`, so all initial
  chunks (including the deferred vendor-web-vitals chunk Vite preloads
  via `<link rel='modulepreload'>`) finish before we drop the network.
- `await expect(page.locator('#root > *').first()).toBeAttached();` —
  proof that React's first commit landed, so the OfflineBanner is in
  the DOM and the only thing left under test is its reaction to the
  `offline` event (which is what the assertion actually wants to cover).

This brings the chromium @Extended lane of the nightly extended-e2e
workflow back to green. The webkit / mobile-safari @auth lanes have a
pre-existing, separately-tracked flake in
`tests/smoke/auth-webkit.spec.ts` (Better Auth + Safari ITP cookie
regression — see PR-48); that is out of scope for this PR.

Verification:

- `pnpm --filter @sergeant/web exec prettier --check
  apps/web/tests/smoke/navigation-offline-sw.spec.ts` — passes
- `pnpm --filter @sergeant/web exec eslint
  tests/smoke/navigation-offline-sw.spec.ts` — 0 errors

The actual E2E run is triggered via workflow_dispatch on this branch
once it's pushed; the chromium lane should go green for the first time
since #2510 was filed.

Co-Authored-By: dmytro.s.stakhov <dmytro.s.stakhov@gmail.com>
Skords-01 added a commit that referenced this pull request May 13, 2026
Re-#2510 — extended-e2e nightly was failing at `Wait for servers` because
the server's `dev` script hard-failed on a missing .env. That root cause
is fixed in #2522. Re-running the suite via workflow_dispatch (run
#25821506416) surfaced the next failure mode in the same test:

  `@extended offline: shows OfflineBanner status`

The chromium screenshot was a fully blank page and the trace showed:

  Failed to load resource: net::ERR_INTERNET_DISCONNECTED
    http://127.0.0.1:4173/assets/vendor-web-vitals-Dr8xtVUp.js
    http://127.0.0.1:4173/assets/dm-sans-latin-{wght,ext-wght}-…woff2

Root cause is a navigation/timing race:

1. Playwright resolves `page.goto('/', { waitUntil: 'domcontentloaded' })`
   the moment HTML parsing finishes.
2. We immediately call `context.setOffline(true)`.
3. `apps/web/src/main.tsx` schedules a deferred init via
   `requestIdleCallback(scheduleInit, { timeout: 2000 })` which dynamically
   imports `web-vitals` — that chunk request now resolves with
   `net::ERR_INTERNET_DISCONNECTED`.
4. The font subsets that CSS resolution is waiting on also fail with the
   same error, so React's first paint never happens. `#root` stays empty
   and `OfflineBanner` is never mounted.

The test then waits 10 s for a banner that has no React tree to live in,
and fails.

The fix is on the test side, not the app side: the production app
behaves correctly here — when the user actually loses the network
mid-session, web-vitals has long since been imported. The test was just
flipping the network before the app finished initial bootstrapping.

Two changes to make the test deterministic:

- `waitUntil: 'domcontentloaded'` → `waitUntil: 'load'`, so all initial
  chunks (including the deferred vendor-web-vitals chunk Vite preloads
  via `<link rel='modulepreload'>`) finish before we drop the network.
- `await expect(page.locator('#root > *').first()).toBeAttached();` —
  proof that React's first commit landed, so the OfflineBanner is in
  the DOM and the only thing left under test is its reaction to the
  `offline` event (which is what the assertion actually wants to cover).

This brings the chromium @Extended lane of the nightly extended-e2e
workflow back to green. The webkit / mobile-safari @auth lanes have a
pre-existing, separately-tracked flake in
`tests/smoke/auth-webkit.spec.ts` (Better Auth + Safari ITP cookie
regression — see PR-48); that is out of scope for this PR.

Verification:

- `pnpm --filter @sergeant/web exec prettier --check
  apps/web/tests/smoke/navigation-offline-sw.spec.ts` — passes
- `pnpm --filter @sergeant/web exec eslint
  tests/smoke/navigation-offline-sw.spec.ts` — 0 errors

The actual E2E run is triggered via workflow_dispatch on this branch
once it's pushed; the chromium lane should go green for the first time
since #2510 was filed.

Co-Authored-By: dmytro.s.stakhov <dmytro.s.stakhov@gmail.com>
Skords-01 added a commit that referenced this pull request May 13, 2026
Re-#2510 — extended-e2e nightly was failing at `Wait for servers` because
the server's `dev` script hard-failed on a missing .env. That root cause
is fixed in #2522. Re-running the suite via workflow_dispatch (run
#25821506416) surfaced the next failure mode in the same test:

  `@extended offline: shows OfflineBanner status`

The chromium screenshot was a fully blank page and the trace showed:

  Failed to load resource: net::ERR_INTERNET_DISCONNECTED
    http://127.0.0.1:4173/assets/vendor-web-vitals-Dr8xtVUp.js
    http://127.0.0.1:4173/assets/dm-sans-latin-{wght,ext-wght}-…woff2

Root cause is a navigation/timing race:

1. Playwright resolves `page.goto('/', { waitUntil: 'domcontentloaded' })`
   the moment HTML parsing finishes.
2. We immediately call `context.setOffline(true)`.
3. `apps/web/src/main.tsx` schedules a deferred init via
   `requestIdleCallback(scheduleInit, { timeout: 2000 })` which dynamically
   imports `web-vitals` — that chunk request now resolves with
   `net::ERR_INTERNET_DISCONNECTED`.
4. The font subsets that CSS resolution is waiting on also fail with the
   same error, so React's first paint never happens. `#root` stays empty
   and `OfflineBanner` is never mounted.

The test then waits 10 s for a banner that has no React tree to live in,
and fails.

The fix is on the test side, not the app side: the production app
behaves correctly here — when the user actually loses the network
mid-session, web-vitals has long since been imported. The test was just
flipping the network before the app finished initial bootstrapping.

Two changes to make the test deterministic:

- `waitUntil: 'domcontentloaded'` → `waitUntil: 'load'`, so all initial
  chunks (including the deferred vendor-web-vitals chunk Vite preloads
  via `<link rel='modulepreload'>`) finish before we drop the network.
- `await expect(page.locator('#root > *').first()).toBeAttached();` —
  proof that React's first commit landed, so the OfflineBanner is in
  the DOM and the only thing left under test is its reaction to the
  `offline` event (which is what the assertion actually wants to cover).

This brings the chromium @Extended lane of the nightly extended-e2e
workflow back to green. The webkit / mobile-safari @auth lanes have a
pre-existing, separately-tracked flake in
`tests/smoke/auth-webkit.spec.ts` (Better Auth + Safari ITP cookie
regression — see PR-48); that is out of scope for this PR.

Verification:

- `pnpm --filter @sergeant/web exec prettier --check
  apps/web/tests/smoke/navigation-offline-sw.spec.ts` — passes
- `pnpm --filter @sergeant/web exec eslint
  tests/smoke/navigation-offline-sw.spec.ts` — 0 errors

The actual E2E run is triggered via workflow_dispatch on this branch
once it's pushed; the chromium lane should go green for the first time
since #2510 was filed.

Co-Authored-By: dmytro.s.stakhov <dmytro.s.stakhov@gmail.com>
Skords-01 added a commit that referenced this pull request May 13, 2026
Re-#2510 — extended-e2e nightly was failing at `Wait for servers` because
the server's `dev` script hard-failed on a missing .env. That root cause
is fixed in #2522. Re-running the suite via workflow_dispatch (run
#25821506416) surfaced the next failure mode in the same test:

  `@extended offline: shows OfflineBanner status`

The chromium screenshot was a fully blank page and the trace showed:

  Failed to load resource: net::ERR_INTERNET_DISCONNECTED
    http://127.0.0.1:4173/assets/vendor-web-vitals-Dr8xtVUp.js
    http://127.0.0.1:4173/assets/dm-sans-latin-{wght,ext-wght}-…woff2

Root cause is a navigation/timing race:

1. Playwright resolves `page.goto('/', { waitUntil: 'domcontentloaded' })`
   the moment HTML parsing finishes.
2. We immediately call `context.setOffline(true)`.
3. `apps/web/src/main.tsx` schedules a deferred init via
   `requestIdleCallback(scheduleInit, { timeout: 2000 })` which dynamically
   imports `web-vitals` — that chunk request now resolves with
   `net::ERR_INTERNET_DISCONNECTED`.
4. The font subsets that CSS resolution is waiting on also fail with the
   same error, so React's first paint never happens. `#root` stays empty
   and `OfflineBanner` is never mounted.

The test then waits 10 s for a banner that has no React tree to live in,
and fails.

The fix is on the test side, not the app side: the production app
behaves correctly here — when the user actually loses the network
mid-session, web-vitals has long since been imported. The test was just
flipping the network before the app finished initial bootstrapping.

Two changes to make the test deterministic:

- `waitUntil: 'domcontentloaded'` → `waitUntil: 'load'`, so all initial
  chunks (including the deferred vendor-web-vitals chunk Vite preloads
  via `<link rel='modulepreload'>`) finish before we drop the network.
- `await expect(page.locator('#root > *').first()).toBeAttached();` —
  proof that React's first commit landed, so the OfflineBanner is in
  the DOM and the only thing left under test is its reaction to the
  `offline` event (which is what the assertion actually wants to cover).

This brings the chromium @Extended lane of the nightly extended-e2e
workflow back to green. The webkit / mobile-safari @auth lanes have a
pre-existing, separately-tracked flake in
`tests/smoke/auth-webkit.spec.ts` (Better Auth + Safari ITP cookie
regression — see PR-48); that is out of scope for this PR.

Verification:

- `pnpm --filter @sergeant/web exec prettier --check
  apps/web/tests/smoke/navigation-offline-sw.spec.ts` — passes
- `pnpm --filter @sergeant/web exec eslint
  tests/smoke/navigation-offline-sw.spec.ts` — 0 errors

The actual E2E run is triggered via workflow_dispatch on this branch
once it's pushed; the chromium lane should go green for the first time
since #2510 was filed.

Co-Authored-By: dmytro.s.stakhov <dmytro.s.stakhov@gmail.com>
Skords-01 added a commit that referenced this pull request May 13, 2026
Re-#2510 — extended-e2e nightly was failing at `Wait for servers` because
the server's `dev` script hard-failed on a missing .env. That root cause
is fixed in #2522. Re-running the suite via workflow_dispatch (run
#25821506416) surfaced the next failure mode in the same test:

  `@extended offline: shows OfflineBanner status`

The chromium screenshot was a fully blank page and the trace showed:

  Failed to load resource: net::ERR_INTERNET_DISCONNECTED
    http://127.0.0.1:4173/assets/vendor-web-vitals-Dr8xtVUp.js
    http://127.0.0.1:4173/assets/dm-sans-latin-{wght,ext-wght}-…woff2

Root cause is a navigation/timing race:

1. Playwright resolves `page.goto('/', { waitUntil: 'domcontentloaded' })`
   the moment HTML parsing finishes.
2. We immediately call `context.setOffline(true)`.
3. `apps/web/src/main.tsx` schedules a deferred init via
   `requestIdleCallback(scheduleInit, { timeout: 2000 })` which dynamically
   imports `web-vitals` — that chunk request now resolves with
   `net::ERR_INTERNET_DISCONNECTED`.
4. The font subsets that CSS resolution is waiting on also fail with the
   same error, so React's first paint never happens. `#root` stays empty
   and `OfflineBanner` is never mounted.

The test then waits 10 s for a banner that has no React tree to live in,
and fails.

The fix is on the test side, not the app side: the production app
behaves correctly here — when the user actually loses the network
mid-session, web-vitals has long since been imported. The test was just
flipping the network before the app finished initial bootstrapping.

Two changes to make the test deterministic:

- `waitUntil: 'domcontentloaded'` → `waitUntil: 'load'`, so all initial
  chunks (including the deferred vendor-web-vitals chunk Vite preloads
  via `<link rel='modulepreload'>`) finish before we drop the network.
- `await expect(page.locator('#root > *').first()).toBeAttached();` —
  proof that React's first commit landed, so the OfflineBanner is in
  the DOM and the only thing left under test is its reaction to the
  `offline` event (which is what the assertion actually wants to cover).

This brings the chromium @Extended lane of the nightly extended-e2e
workflow back to green. The webkit / mobile-safari @auth lanes have a
pre-existing, separately-tracked flake in
`tests/smoke/auth-webkit.spec.ts` (Better Auth + Safari ITP cookie
regression — see PR-48); that is out of scope for this PR.

Verification:

- `pnpm --filter @sergeant/web exec prettier --check
  apps/web/tests/smoke/navigation-offline-sw.spec.ts` — passes
- `pnpm --filter @sergeant/web exec eslint
  tests/smoke/navigation-offline-sw.spec.ts` — 0 errors

The actual E2E run is triggered via workflow_dispatch on this branch
once it's pushed; the chromium lane should go green for the first time
since #2510 was filed.

Co-Authored-By: dmytro.s.stakhov <dmytro.s.stakhov@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant