> 🤖 This PR was written by Coder Agents on behalf of Jake Howell.
Stack:
1. #26575 `fix(site/e2e): close mock external-auth servers in teardown`
2. #26793 `fix(site/e2e): accept 404 from external auth reset hook`
3. #26795 `fix(site/src): refresh provider state after device-flow
exchange`
4. #26798 `fix(site/e2e): reset both providers in external auth hook`
5. #26648 `chore(site/e2e): re-enable externalAuth suite` ← this PR
Removes the `test.describe.skip` introduced in #17235 so the
externalAuth suite runs in CI again.
Splitting the five commits keeps the bisection signal clean if CI
behaviour changes after the un-skip.
Local verification of the full stack: `pnpm playwright:test
e2e/tests/externalAuth.spec.ts --repeat-each=20 --workers=1` → 61/61
passing in 4m48s.
Large whitespace churn in the diff is biome reformatting the body to its
natural indentation after the single-line `test.describe(...)` opener.
The only semantic change is removing `.skip`.
Refs https://linear.app/codercom/issue/DEVEX-413
Refs https://github.com/coder/internal/issues/356
## Summary
Deflakes the `adjust user theme preference` Playwright test
(`site/e2e/tests/users/userSettings.spec.ts`), tracked in DEVEX-415.
The test selected the Light theme and then hard-navigated with
`page.goto`
before the optimistic appearance update was persisted. The navigation
could
cancel the in-flight `PUT /api/v2/users/me/appearance`, so the reloaded
document embedded the stale `dark` preference and the final assertion
flaked.
`toPass` retries could not help because retrying the reload only
re-reads the
still-stale persisted state.
## Fix
Wait for the appearance form's save spinner to clear before the hard
reload,
mirroring how other settings tests wait for a visible save confirmation.
Asserting the optimistic light class first guarantees the spinner is
already
showing if a save started; a repeat run that is already light never
shows it,
so the test is idempotent and there are no direct API calls.
To give the test a UI signal, `Spinner` gets an opt-in `label` prop that
exposes it as a `role="status"` live region with an `aria-label`
(decorative
otherwise). `Loader` moves its label onto its own status container so it
keeps
a single status region.
## Changes
- `site/src/components/Spinner/Spinner.tsx`: opt-in `label` prop.
- `site/src/components/Loader/Loader.tsx`: label on its own status
region.
- `site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx`:
label
both appearance save spinners.
- `site/e2e/tests/users/userSettings.spec.ts`: wait for the save
spinner.
## Validation
- `biome check` and `tsc -p .` pass.
- Loader + AppearancePage unit tests and the affected storybook tests
pass.
- The e2e test passed 20/20 under
`pnpm playwright:test -g "adjust user theme preference" --repeat-each
20`.
<details>
<summary>Implementation plan & decision log</summary>
# DEVEX-415: Fix flake in "adjust user theme preference" e2e test
## Problem
Playwright test `site/e2e/tests/users/userSettings.spec.ts` → `adjust
user
theme preference` flakes. After selecting the Light theme and
hard-navigating
to `/`, the reloaded page sometimes stays `dark`, failing the final
assertion.
CI Flake Bot has recorded repeated recurrences on `main` (latest
2026-08-17,
runs `32004239187`, `31745261984`) even after PR #25183 added `toPass`
retries.
## Root cause (confirmed by reading the code)
The appearance update is optimistic and its persistence is not awaited
before
navigation:
- `updateAppearanceSettings` (`site/src/api/queries/users.ts`) has an
`onMutate` that optimistically writes the new theme into the React Query
cache. The `<html>` class flips to `light` immediately, before the
`PUT /api/v2/users/me/appearance` completes.
- `useQueuedAppearanceSubmit`
(`site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.tsx`)
serializes submits: if a request is in flight, the next is queued and
only
fires after the first settles.
- A fresh `member` user has **empty** appearance settings.
`migrateLegacyPreference` (`site/src/theme/themeMode.ts`) maps empty
settings
to `{ mode: "single", theme: "dark" }` (`DEFAULT_THEME = "dark"`). So
the
"Theme mode" dropdown already starts on **Single theme** and `<html>`
starts
`dark`. Selecting "Single theme" in the test is therefore a **no-op**
(`onChangeMode` early-returns when `mode === draft.mode`) and fires
**no**
PUT. The only appearance PUT in the test is the one from clicking
"Light default" (`onSelectSingle("light")` → `theme_preference:
"light"`).
- The test's first `expectLightThemeClasses(page)` passes purely from
the
optimistic cache. It then calls `page.goto("/")` almost immediately
(~20 ms after the click). If PUT #2 (the light one) has not persisted
server-side, the new document loads the still-persisted `dark`
preference
from embedded metadata, and every retry of the post-navigation assertion
sees `dark` for the full 10 s window.
`toPass` cannot help post-navigation because it only re-reads stale,
already
persisted state; it cannot make an unfinished/queued PUT complete.
## Implemented fix: wait for the saving spinner (UI signal) before
navigation
> Iteration history: (1) An earlier attempt waited on the appearance
`PUT` via
> a `waitForApiCall` helper, but that couples the test to a state
transition
> and is non-idempotent (a repeat run that is already light fires no
PUT, so
> the wait times out). CI retries reuse the same ephemeral server, so
this is a
> real hazard. (2) A second attempt confirmed persistence with
> `page.request.get(...)`, but that calls the API directly and stops
being a
> site test. Both were rejected.
The repo's non-flaky settings tests wait for a **visible save
confirmation**
(e.g. "settings updated successfully" toasts) before trusting the
result. The
appearance theme form has no toast; its only save-in-progress feedback
is the
`<Spinner>`. So the fix mirrors that pattern using the spinner:
1. Make the theme section's spinner identifiable via a new opt-in
`label` prop
on `Spinner` (sets `role="status"` + `aria-label` only when provided;
decorative otherwise). `Loader` moves its label onto its own `status`
container so it keeps a single status region and its `getByLabelText`
queries keep working.
2. In the test, after clicking "Light default", assert the optimistic
light
class, then wait for that spinner to be hidden before `page.goto("/")`.
Why this is correct and idempotent:
- React Query sets `isPending` before `onMutate` applies the optimistic
cache
update, so by the time the optimistic light class is visible the spinner
is
already showing if a save started. Waiting for it to clear guarantees
the PUT
settled (and was not canceled by navigation) before the reload.
- A repeat run that is already light: clicking "Light default" is a
no-op radio
change, no PUT fires, the spinner never shows, and `toBeHidden` passes
immediately. The reload still shows light.
- No direct API calls: the test only observes site UI.
### Implemented changes
`Spinner` gains an opt-in `label` prop
(`site/src/components/Spinner/Spinner.tsx`):
```tsx
role={label ? "status" : undefined}
aria-label={label}
```
`Loader` carries the label on its own status container
(`site/src/components/Loader/Loader.tsx`), and the appearance save
spinners use
the new prop (`AppearanceForm.tsx`):
```tsx
<Spinner loading={isUpdating} size="sm" label="Saving theme preference" />
<Spinner loading={isUpdating} size="sm" label="Saving terminal font" />
```
`site/e2e/tests/users/userSettings.spec.ts`:
```ts
await expect(
page.getByRole("combobox", { name: /theme mode/i }),
).toContainText("Single theme"); // precondition: single mode
const singleThemeGroup = page.getByRole("group", { name: "Theme" });
await expect(singleThemeGroup).toBeVisible();
await singleThemeGroup.getByText("Light default", { exact: true }).click();
await expectLightThemeClasses(page); // optimistic DOM => spinner showing if saving
await expect(
page.getByRole("status", { name: "Saving theme preference" }),
).toBeHidden(); // save settled before the hard reload
await page.goto("/", { waitUntil: "domcontentloaded" });
await expectLightThemeClasses(page);
```
Validation: `biome check`, `tsc -p .` pass; Loader + AppearancePage unit
tests
and the affected storybook tests pass; the e2e test passed 20/20 under
`pnpm playwright:test -g "adjust user theme preference" --repeat-each
20`
(idempotent).
## Alternatives considered
- **Wait on the appearance `PUT` via a `waitForApiCall` helper**:
rejected. It
couples the test to a state transition and is non-idempotent, a repeat
run
that is already light fires no PUT so the wait times out (14/15 repeats
failed). CI retries reuse the same ephemeral server, so this is a real
hazard, not just a local-repeat artifact.
- **Confirm persistence with `page.request.get(...)`**: rejected. It
calls the
API directly and stops being a site test.
- **Default `role="status"` on the shared `Spinner`**: rejected.
`Loader` wraps
`Spinner` in its own `status` div, so a default would nest two status
regions and break `Loader.test.tsx`. The opt-in `label` prop avoids
this.
- **Add a networkidle wait or sleep before navigation**: rejected.
Violates
the repo guidance against `time.Sleep`-style timing hacks and is
inherently
racy.
- **Fix product behavior instead of the test**: out of scope. Related
bug
DEVEX-94 ("Light Theme setting not respected until Appearance page
opened")
tracks product-side persistence/embedding behavior; this task is scoped
to
stabilizing the e2e test.
## Files touched
- `site/src/components/Spinner/Spinner.tsx` (new opt-in `label` prop).
- `site/src/components/Loader/Loader.tsx` (label on its own status
region).
- `site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx`
(use the
`label` prop on both appearance save spinners).
- `site/e2e/tests/users/userSettings.spec.ts` (wait for the spinner).
</details>
---
This PR was created by Coder Agents on behalf of @jeremyruppel.
---------
Co-authored-by: Samuel Volin <sam.volin@coder.com>
## Summary
`e2e/tests/outdatedCLI.spec.ts` has a 30 second budget in which it must
create a template and workspace, start an agent, download an 84 MiB
release binary from GitHub, and then exercise the actual thing under
test: whether a `v2.8.0` client can still SSH into a workspace served by
HEAD. In the run that filed this ticket, `install.sh` spent **20.04
seconds** of that budget on an HTTP request the test does not need,
leaving 5.4 seconds for the download. The SSH flow never executed.
Worth being precise about the shape, because it changes the fix. The
stall is not in the code under test and it is not an SSH problem.
`install.sh` resolves the latest stable release version
**unconditionally**, even when `--version 2.8.0` is passed explicitly,
and on the pinned path that value feeds nothing but a cosmetic
post-install advisory string. Two thirds of the test's budget went to
producing one sentence of console output that the test discards.
Refs: https://github.com/coder/internal/issues/1571
## Problem
### What the test is for
This is a backward-compatibility test, and `v2.8.0` is the compatibility
floor it enforces rather than a "supported version" in the
release-channel sense. The pin traces to one code comment, `we no longer
support versions prior to Tailnet v2 API support`, citing 059e533544;
that commit first shipped in v2.8.0, so the pin sits exactly on the
boundary it names. Worth stating plainly: this is the oldest client
expected to still interoperate, not a version that receives patches.
Release support is mainline / stable / n-2 / ESR, all far newer.
The test's value is that it runs the *real* historical binary, compiled
in Feb 2024, against a current server: `codersdk` REST compatibility,
tailnet coordination v2, DERP negotiation, `coder ssh --stdio` as an SSH
transport, and the agent accepting a session. Nobody gets to assert what
that old client sends over the wire, which is exactly why the binary has
to be downloaded rather than faked.
The structural defect is that the download shares a timeout with the
assertion:
```text
┌─────────────────────────────────────────────────────────────────┐
│ ONE 30-second Playwright test budget │
├──────────────────────────────┬──────────────────────────────────┤
│ What we want to measure │ Incidental setup │
│ (deterministic, local) │ (network, non-deterministic) │
│ │ │
│ • template + workspace │ • HTTP HEAD to github.com │
│ • agent connect │ • 84 MiB download from │
│ • coder ssh --stdio │ GitHub release CDN │
│ • SSH handshake + exec │ • tar extraction │
│ • workspace stop │ │
└──────────────────────────────┴──────────────────────────────────┘
~7-14 s, stable 0 s (cached) .. ∞ (unbounded)
```
A test that asserts protocol compatibility should not be able to fail
because `github.com` was slow.
### The evidence
CI runs Playwright with `DEBUG: pw:api`, and `downloadCoderVersion`
passes `TRACE=1` to `install.sh`, which makes it `set -x`. The job log
therefore stamps every phase. Reconstructed from [job
80049661296](https://github.com/coder/coder/actions/runs/27124540074/job/80049661296),
`t=` relative to test start:
```text
t=+0.000 08:17:44.671 browserContext.newPage <- test starts
t=+0.882 08:17:45.553 login complete
t=+4.228 08:17:48.899 workspace create submitted
t=+4.519 08:17:49.190 agent-status-ready visible <- startAgent returns
t=+4.526 08:17:49.197 install.sh: parse_arg --version 2.8.0 ... <- downloadCoderVersion
t=+4.531 08:17:49.202 curl -sSLI https://github.com/coder/coder/releases/latest
:
: 20.042 SECONDS OF NOTHING
: (agent logs keepalives; the page sits idle)
:
t=+24.573 08:18:09.244 response= 200 .../releases/tag/v2.33.6 <- probe returns
t=+24.575 08:18:09.246 STABLE_VERSION=2.33.6 <- feeds a log line
t=+24.582 08:18:09.253 curl -#fL -o .../coder_2.8.0_linux_amd64.tar.gz.incomplete
: 5.4 s of an 84 MiB download
t=+30.000 08:18:14.671 Playwright kills the test
```
Three observations rule out the originally suspected cause (slow SSH
readiness or general runner slowness):
- **The SSH flow never started.** `sshIntoWorkspace` is called after
`downloadCoderVersion` returns, and it never returned. There is no
`coder ssh --stdio` process in the log.
- **The agent was healthy.** `agent-status-ready` resolved in 88 ms, and
through the entire 20 second stall the agent logs a live DERP
connection, successful STUN, and a completed wireguard handshake.
- **The runner was fast, not slow.** Login plus template plus workspace
plus agent took 4.5 seconds.
### Where the 20 seconds goes
```text
install.sh main()
...
L431 STABLE_VERSION=$(echo_latest_stable_version) <- ALWAYS runs
|
+-- echo_latest_stable_version() (install.sh:94)
curl -sSLI https://github.com/coder/coder/releases/latest
# no --connect-timeout
# no --max-time
# non-200 => exit 1 (hard failure)
L454-461 the only consumers when --version is pinned:
if VERSION == STABLE_VERSION: STABLE=1
L148 advisory="To install our stable release (v${STABLE_VERSION}), ..."
L159 "Coder ${channel}release v${VERSION} installed. ${advisory}"
```
That is the whole dependency chain. `-sSLI` also follows redirects and
`/releases/latest` *is* a redirect, so this is at minimum two
round-trips to `github.com` with no timeout ceiling on either.
### Why 30 seconds and not 60
`test.setTimeout(60_000)` used to be on this test. #16236 removed it,
and that removal was deliberate: it was itself a flake fix
(coder/internal#204, #279) whose thesis was that `go run` compiling
inside a resource-constrained test run was the problem. Having pre-built
the binary, it consistently stripped the allowances that existed to
absorb compile time:
| File | Change in #16236 | Was that allowance really compile time? |
|---|---|---|
| `app.spec.ts` | `setTimeout(75_000)` removed, click timeout `60_000`
-> `10_000` | Yes |
| `webTerminal.spec.ts` | `setTimeout(75_000)` removed | Yes |
| `helpers.ts` | agent-ready wait `45_000` -> `15_000` | Yes |
| `outdatedCLI.spec.ts` | `setTimeout(60_000)` removed | **No: also an
84 MiB download** |
| `outdatedAgent.spec.ts` | timeout untouched, 60 s survives | n/a |
The reasoning was sound and the sweep internally consistent. It had one
blind spot: for `app.spec.ts` and `webTerminal.spec.ts` that budget
genuinely was the compiler's, but here it covered compile time **plus**
a release download, and only the compile half went away. With 60
seconds, the failing run above would have finished in roughly 31 to 43
seconds and passed.
### Budget arithmetic
At `t=+24.58` the test still had to do:
| Remaining work | Realistic cost |
|---|---:|
| Download 84 MiB tarball | 2 - 8 s |
| `tar` extract | 0.3 - 1 s |
| `coder ssh --stdio` cold start | 0.5 - 2 s |
| Tailnet dial + SSH handshake | 1 - 3 s |
| `stopWorkspace` | 2 - 4 s |
| **Needed** | **~6 - 18 s** |
| **Available** | **5.42 s** |
## Fix
Move the download into the existing `testsSetup` Playwright project,
where it gets a 300 second budget and where a failure is attributed to
the download rather than to SSH.
```mermaid
flowchart TB
subgraph BEFORE["BEFORE: one budget, two concerns"]
direction TB
T1["tests project, timeout 30s"]
T1A["outdatedCLI.spec.ts<br/>login / template / workspace / agent<br/><b>downloadCoderVersion <- NETWORK</b><br/>sshIntoWorkspace / exec / stopWorkspace"]
T1 --> T1A
end
subgraph AFTER["AFTER: network work has its own clock"]
direction TB
S2["testsSetup project, timeout 300s"]
S2A["downloadCoderVersions.spec.ts<br/>stable-version probe + 84 MiB + retries<br/>all live HERE"]
T2["tests project, timeout 60s"]
T2A["outdatedCLI.spec.ts<br/>downloadCoderVersion = cache hit, ~300ms<br/>SSH path gets the whole budget"]
S2 --> S2A
S2A -- "dependencies" --> T2
T2 --> T2A
end
BEFORE ~~~ AFTER
style T1A fill:#ffe5e5,stroke:#cc0000,stroke-width:2px
style S2A fill:#e5ffe5,stroke:#007700,stroke-width:2px
style T2A fill:#e5ffe5,stroke:#007700,stroke-width:2px
```
### Why it works
`downloadCoderVersion` was already idempotent and cache-checking: it
spawns `<binaryPath> version` first and returns early on exit 0. So the
test keeps its existing call and that call simply becomes a no-op
costing a few hundred milliseconds. **No test logic changes.**
```mermaid
sequenceDiagram
autonumber
participant S as testsSetup:<br/>downloadCoderVersions
participant IS as install.sh
participant GH as github.com
participant T as tests:<br/>outdatedCLI
participant CD as coderd + agent
Note over S: budget 300s
S->>IS: downloadCoderVersion(v2.8.0)
IS->>GH: stable-version probe (unbounded)
IS->>GH: fetch 84 MiB asset
GH-->>IS: /tmp/coder-e2e-cache/bin/coder-e2e-2.8.0
IS-->>S: binaryPath
Note over T: budget 60s, local only
T->>T: downloadCoderVersion(v2.8.0)
Note right of T: spawn "<bin> version" -> exit 0<br/>returns early, ~300ms, no network
T->>CD: coder ssh --stdio, handshake, exec "exit 0"
CD-->>T: exit code 0
```
### Why the prefetch is non-fatal
The obvious implementation raises on failure. That would be wrong here,
and I verified why rather than assuming: `tests` declares `dependencies:
["testsSetup"]`, and a failing setup project stops dependent tests from
**running at all**. Adding a deliberately-throwing setup spec produced:
```text
✓ 1 [testsSetup] › addUsersAndLicense.spec.ts › setup deployment (11.7s)
✓ 2 [testsSetup] › downloadCoderVersions.spec.ts › download outdated CLI (353ms)
✘ 3 [testsSetup] › zzTempFail.spec.ts › temporary blast radius probe (0ms)
1 failed
1 did not run <- outdatedCLI never ran
2 passed
```
So raising would convert a one-test flake into a whole-suite outage on
any GitHub hiccup. Instead the prefetch logs a warning and returns, and
the test's own `downloadCoderVersion` call fetches inline as it does
today. The failure path is therefore no worse than the status quo, and
the success path removes the network from the test entirely.
Of the three policies available (fail hard, fall back inline, or skip
the test), this is the only one that cannot regress anything: it never
blocks the suite, and it never silently drops coverage the way an
auto-skip would.
### Restoring the 60 second budget
This is the second half of the change, and it exists for the fallback
path above. It cannot reintroduce what #16236 fixed: the timeout value
has no causal relationship to how the binary is produced, `coderBinary`
stays pre-built, `go run` stays gone, and only `outdatedCLI.spec.ts` is
touched.
It does give back a bounded sliver of the CI-latency goal, and the bound
is small. A passing run is unaffected. The cost lands only when this one
test hangs, and then it is +30 s once: `--workers 1` so there is no
fan-out, `CODER_E2E_TEST_RETRIES` is unset in CI so `retries` is 0 and
nothing multiplies it, and the job budget is `timeout-minutes: 20`.
## Measurements
Four scenarios, locally on darwin/arm64 against a freshly built
`site/e2e/bin/coder`:
| Scenario | setup spec | `outdatedCLI` | `install.sh` inside the test?
| Result |
|---|---:|---:|---|---|
| Cold, empty cache | 5.7 s | 10.0 s | **no**, ran in setup | ✓ passed |
| Warm cache | 340 ms | 11.9 s | **no**, 0 invocations | ✓ passed |
| Prefetch fails, cache empty | 1 ms | 15.4 s | yes, inline fallback | ✓
passed |
| Setup spec throws | n/a | did not run | n/a | blast radius above |
The cold run is the load-bearing one: `install.sh` is invoked from the
setup spec and the test runs local-only in 10.0 s, so the 84 MiB
download and the 20 s probe are no longer on the assertion's clock.
For context on what "local only" costs, eight consecutive `main` runs
where the CI cache already made `install.sh` a no-op:
| Job | duration |
|---|---:|
| 90382498172 | 11.7 s |
| 90352398170 | 12.1 s |
| 90335547858 | 8.1 s |
| 90317232684 | 13.7 s |
| 90297156949 | 8.4 s |
| 90280065546 | 6.9 s |
| 90265047082 | 6.9 s |
| 90250803989 | 6.6 s |
6.6 to 13.7 seconds. This change makes that the only path rather than
the lucky one.
Also checked: the test name is byte identical (`ssh with client v2.8.0`)
so flake tracking keeps matching it, `outdatedAgent` remains skipped,
and `webTerminal`, `auditLogs`, and `updateTemplate` still pass, so the
added setup dependency disturbs nothing. The full 60-test suite was not
run locally because the premium tests need `CODER_E2E_LICENSE`.
## Also in this change
The pinned versions move to `site/e2e/constants.ts` as
`oldestSupportedCLIVersion` and `oldestSupportedAgentVersion`, so the
setup spec and the tests share one source of truth, and the comments
explaining *why* those particular versions travel with them. The CI
cache key follows them there: it previously hashed the two spec files,
and now hashes `constants.ts`, so it still invalidates exactly when a
pinned version changes.
## Not addressed here
The 20 second probe is relocated, not removed. `install.sh` still
resolves the latest stable version on every pinned install, with no
`--connect-timeout` or `--max-time`, and still treats a non-200 as
fatal, so a GitHub hiccup can fail an install whose target tarball is
already cached locally. That is a user-facing bug in its own right and
wants its own PR, since fixing it means deciding what a pinned install
should print when we no longer look up what "stable" currently is.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
> 🤖 This PR was written by Coder Agents on behalf of Jake Howell.
Stack:
1. #26575 `fix(site/e2e): close mock external-auth servers in teardown`
← this PR
2. #26793 `fix(site/e2e): accept 404 from external auth reset hook`
3. #26795 `fix(site/src): refresh provider state after device-flow
exchange`
4. #26798 `fix(site/e2e): reset both providers in external auth hook`
5. #26648 `chore(site/e2e): re-enable externalAuth suite`
The externalAuth e2e suite has been skipped since #17235 because
`createServer` in `site/e2e/helpers.ts` started an express server but
never gave callers a way to close it. On retries or repeated runs, the
listener from the previous invocation was still bound to the hardcoded
port and the next `beforeAll` failed with `EADDRINUSE`, eventually
timing out in `waitForPort`.
`createServer` now returns a `{ app, close }` pair. The web flow closes
in `afterAll`; the device flow uses `try/finally`.
`closeAllConnections()` is called before `close()` so teardown stays
bounded if keep-alive connections linger.
The suite remains `test.describe.skip` here; #26648 flips the skip off
once the rest of the stack is in.
Refs https://linear.app/codercom/issue/DEVEX-413
Refs https://github.com/coder/internal/issues/356
<details>
<summary>Decision log</summary>
Discussed the full options list with @jakehwll before drafting. Picked
option A (minimal teardown) because:
- Two prior PRs (#15537, #16528) attacked symptoms (port probing, longer
timeout) without addressing the leaked listener.
- Kayla's diagnosis on coder/internal#356 pointed at exactly this case:
nothing else in CI is grabbing the port, the listener from the previous
run is still bound.
- A is mechanical and orthogonal: it adds a real teardown without
changing port allocation, fixture wiring, or what gets mocked. If the
flake persists after A, we know to escalate to a worker-scoped fixture
or dynamically allocated ports.
Returning `close` rather than the raw `http.Server` encapsulates the
`closeAllConnections` + `close` choreography so callers don't repeat it.
`closeAllConnections` is optional-chained because it landed in Node
18.2; coder/coder runs newer, but the chain costs nothing.
The device test uses `try/finally` rather than a shared `afterEach` to
keep per-test state local. The web flow's `afterAll` mirrors its
`beforeAll`.
</details>
## Summary
- Wire the Appearance settings page to the new theme mode dropdown and
sync or single theme selectors.
- Update Appearance page tests and stories for theme mode behavior.
- Update the user settings e2e test to exercise single theme selection.
## Dependencies
- Depends on #25076, #25180, #25181, and #25182.
- This PR targets helper branch `pr25077/05-theme-mode-dropdown-base`,
which contains dependency commits only, so this PR diff stays focused on
final dropdown wiring. Rebase and retarget after the dependency PRs
merge.
## Validation
- `pnpm -C site exec vitest run --project=unit
src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx
src/theme/themeMode.test.ts src/api/queries/users.test.ts`
- `pnpm -C site lint:types`
- `pnpm -C site storybook:ci`
- `pnpm -C site build`
- `pnpm -C site playwright:test -- e2e/tests/users/userSettings.spec.ts`
- Pre-commit hook passed on the branch commit.
Fixes https://linear.app/codercom/issue/CODAGT-350
On OSS or no-license single-org deployments, the Organizations admin
link was hidden because `canViewOrganizationSettings` was gated on
`showOrganizations`, which requires either a multi-org entitlement or >1
org. The page was still reachable via direct URL, but the members view
displayed a raw "Template RBAC is a Premium feature. Contact sales!"
error from the groups API.
Two fixes:
1. Always render the Organizations link inside the `DeploymentDropdown`.
The dropdown itself is only shown to users with admin-level permissions,
so Organizations is effectively gated on having admin access.
2. Remove `groupsByUserIdQuery.error` from the error chain on the
members page. The groups endpoint is gated behind
`templateRBACEnabledMW` on enterprise, returning a 403 on OSS. The
groups data is already optional, so the page renders fine without it.
> Generated by Coder Agents
This is part 1 to lay the foundation for the theme changes.
Part 2 which adds the UI implementation is in this PR,
https://github.com/coder/coder/pull/24680
-----------------
Adds four sitewide colorblind-friendly theme palettes alongside the
existing
light and dark themes. The palettes retune the red/green and blue/yellow
semantic axes so success/error, warning, and diff additions/deletions
remain distinguishable under the most common color vision deficiencies.
| Preference ID | Purpose |
|---|---|
| `dark-protan-deuter` / `light-protan-deuter` | Protanopia &
deuteranopia (red/green). Success and additions shift to sky-blue;
destructive and deletions shift to vermilion/orange; warning shifts to
fuchsia so warning and destructive states do not collapse onto the same
hue. |
| `dark-tritan` / `light-tritan` | Tritanopia (blue/yellow). Warning
shifts from amber to fuchsia; red/green semantic pair is preserved. |
The diff panel and every semantic role (`success`, `error`, `warning`,
`notice`, `danger`) pick up the new palette automatically because they
consume the sitewide CSS variables in `site/src/index.css`. No backend
or database change is required: `theme_preference` is already a
free-form `text` column.
The existing `"dark"`, `"light"`, and `"auto"` preferences are
unchanged.
This PR ships the palettes and the resolution machinery
(`CONCRETE_THEMES`, `resolveThemeName`, `isConcreteThemeName`, and the
ThemeProvider/AgentEmbedPage plumbing). It intentionally does **not**
add UI to select the new themes; the follow-up PR #24680 adds a Theme
mode dropdown (Sync with system / Single theme) that exposes every
concrete theme, including the four added here.
Produced with Coder Agents assistance.
<details>
<summary>Implementation plan and decision log</summary>
Full plan (investigation, file layout, TDD phases, open risks) is
attached to the chat that produced this PR.
Key decisions:
- **Sitewide, not agent-scoped.** The diff panel already consumes the
sitewide theme via CSS variables. Keeping the change at the user
appearance layer also fixes red/green accents in alerts, badges, and
build states in one change, and matches how comparable products (e.g.
GitHub) ship this feature.
- **No backend change.** `codersdk/users.go` already accepts any
`theme_preference` string, and `ThemeProvider` now has a shared resolver
(`resolveThemeName`) that maps any persisted value to a concrete theme,
tolerating unknowns and the legacy `"auto"` value.
- **Palette provenance.** The protan/deuter palette is inspired by
CVD-safe blue/orange palettes and tuned within the existing Tailwind
color scales; the tritan palette keeps red/green semantics intact and
shifts warning to fuchsia. This PR does not claim exact Okabe-Ito or
WCAG AA derivation without recorded contrast data.
- **UI deferred.** Selecting the new themes is gated on the follow-up PR
#24680 which replaces the flat theme grid with a Theme mode dropdown.
</details>
The terminal page auto-executed commands from the `?command=` query
parameter
on page load without user confirmation. Because session auth uses
`SameSite=Lax`
cookies, an attacker could craft a link (phishing email, Slack DM,
external page)
that executes arbitrary commands in a victim's workspace when clicked.
Adds a `ConfirmDialog` that shows the exact command and requires
explicit user
approval before it is passed to the terminal WebSocket. Canceling
removes the
`command` parameter from the URL and opens a plain terminal.
<details>
<summary>Implementation details</summary>
### Data flow (before)
`TerminalPage.tsx` reads `searchParams.get("command")` and passes it
directly
as `initialCommand` to `WorkspaceTerminal`, which embeds it in the
WebSocket
URL. `proxy.go` forwards it to the agent, which runs `bash -c
"<command>"`
immediately.
### Fix
- Added `commandConfirmed` state and `commandPendingConfirmation` flag
in
`TerminalPage.tsx`.
- The `loading` prop passed to `WorkspaceTerminal` includes
`commandPendingConfirmation`, keeping the terminal in loading state
until
the user confirms or cancels.
- The command is only passed as `initialCommand` after the user clicks
"Run command" in the confirmation dialog.
- Trusted `?app=` commands (resolved from agent apps) bypass the dialog.
- Cancel removes the `?command=` parameter from the URL entirely.
- No backend changes needed; the frontend gates the command before it
reaches the WebSocket.
### Terminal focus after dialog
`WorkspaceTerminal`'s autoFocus effect previously depended on
`[terminal, isVisible, autoFocus]` but not `loading`. It fired while the
Radix dialog's focus trap was active, so `terminal.focus()` was
intercepted. When `loading` became false after confirming the dialog,
the
effect did not re-fire. Fixed by adding `loading` to the effect deps and
skipping focus while `loading` is true.
### Files changed
| File | Change |
|------|--------|
| `site/src/pages/TerminalPage/TerminalPage.tsx` | Confirmation dialog,
`commandPendingConfirmation` in loading prop |
| `site/src/pages/TerminalPage/TerminalCommandConsentDialog.tsx` | New
dialog component |
| `site/src/pages/TerminalPage/TerminalCommandConsentDialog.stories.tsx`
| Storybook story for dialog |
| `site/src/pages/TerminalPage/TerminalPage.stories.tsx` |
`CommandConfirmation` story |
| `site/src/pages/TerminalPage/TerminalPage.test.tsx` | 4 new dialog
tests, `renderTerminalRaw` helper for non-blocking render |
| `site/src/modules/terminal/WorkspaceTerminal.tsx` | Add `loading` to
autoFocus effect deps |
| `site/e2e/helpers.ts` | Dismiss dialog in `openTerminalWindow` helper
|
| `site/e2e/tests/webTerminal.spec.ts` | Wait for
`data-status="connected"` + click terminal for focus |
</details>
> 🤖 Generated by Coder Agents
---------
Co-authored-by: Jakub Domeracki <jakub@coder.com>
The `app` e2e test flakes in CI with `waitForEvent("page")` or
`isVisible()` timeouts when opening a workspace app in a new tab.
The test had several timing hazards: the HTTP server address was read
before the socket finished binding, the popup event listener was
registered after the click, and content visibility used a point-in-time
check with the default 5s timeout.
Rewritten to wait for the server to listen, register the popup promise
before clicking, use `getByRole("link")` for a precise locator, and
replace `isVisible()` with a retrying `toBeVisible({ timeout: 30_000 })`
assertion that tolerates the intermediate `about:blank` page and
app-proxy startup delay. The test body is wrapped in `try/finally` to
ensure the HTTP server is always closed.
Fixescoder/internal#577
> Generated by Coder Agents
---
someone wise once said
> and I really don't know what we could do to make it more reliable
other than maybe trying to just rewrite it from scratch.
This is required to prevent the agent from becoming unhealthy.
Since we are stopping the workspace now, also add a confirmation dialog.
Also add stories to test the new behavior and make a tweak to the
permissions query in support of that.
Closes#22140
Short simple and sweet PR to add a bunch of details to our `<Alert />`
stacks. This means we aren't simply asking the user to read the
developer console and surface things easier.
- Implement `Response data` and `Stack trace` `<details />`
- Fix overflow in `ErrorAlert` debug accordions so long `Response data`
and `Stack Trace` content stays inside the alert.
- Add horizontal scroll wrappers around both `<pre>` blocks used in
debug details.
- Update `Alert` layout with `min-w-0` on flex containers so nested
content can shrink correctly and internal scrolling works as intended.
<img width="739" height="550" alt="preview-validation"
src="https://github.com/user-attachments/assets/a6f890d3-8f1f-4fd6-b9d0-882838db04a4"
/>
Replaces our custom `<GlobalSnackbar />` (MUI Snackbar + event emitter)
with [`sonner`](https://github.com/emilkowalski/sonner). Deletes
`GlobalSnackbar/`, the custom event emitter infra, and migrates ~80
source files to `toast.success()` / `toast.error()` from `sonner`.
- ~47 error toasts now surface API error detail via
`getErrorDetail(error)` in the toast description, not just a generic
message. Coincides with #22229.
- Toast messages follow an `{Action} "{entity}" {result}.` format (e.g.
`User "alice" suspended successfully.`) since toasts persist across
navigation now.
- 17 uses of `toast.promise()` for loading → success → error lifecycle.
- Some toasts include action buttons for quick navigation (e.g. "View
task", "View template").
- Multiple toasts can stack and display simultaneously.
---------
Co-authored-by: Kayla はな <mckayla@hey.com>
### Summary
Workspace created via mode=auto links now require explicit user
confirmation before provisioning. A warning dialog shows all prefilled
param.* values from the URL and blocks creation until the user clicks
`Confirm and Create`. Clicking `Cancel` falls back to the standard form
view.
<img width="820" height="475" alt="auto-create-consent-dialog"
src="https://github.com/user-attachments/assets/8339e3bd-434f-4a04-9385-436bf95f49d7"
/>
### Breaking behavior change
Links using `mode=auto` (e.g., "Open in Coder" buttons) will no longer
silently create workspaces. Users will now see a consent dialog and must
explicitly confirm before the workspace is provisioned. Any existing
integrations or automation relying on `mode=auto` for seamless workspace
creation will now require manual user interaction.
---------
Co-authored-by: Jake Howell <jacob@coder.com>
Provisioner steps broken into smaller granular actions.
Changes:
- `ExtractArchive` moved to `init` request (was in `configure`)
- Writing `tfstate` moved to `plan` (was in `configure`)
- Moved most plan/apply outputs to `GraphComplete`
## Overview
Links in announcement banners are now underlined to make them visually
distinguishable without requiring users to hover over them.
Context:
[Slack](https://codercom.slack.com/archives/C0989BZU23T/p1759503061267819)
## Changes
- Added `text-decoration: underline` to links in the announcement banner
component
---------
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: blink-so[bot] <157993532+blink-so[bot]@users.noreply.github.com>
Co-authored-by: Michael Smith <michaelsmith@coder.com>
I tried to break this work into smaller pieces, but since there are a
lot of dependent components, I decided to handle it in one larger chunk
and rely on Storybook to catch any bugs.
That said, let me know if you’d prefer a different approach!
Solves https://github.com/coder/coder/issues/15096
This is a slight rework/refactor of the earlier PRs from @dannykopping
and @Emyrk:
- https://github.com/coder/coder/pull/15669
- https://github.com/coder/coder/pull/15684
- https://github.com/coder/coder/pull/17596
Rather than having a per-app CORS behaviour setting and additionally a
template level setting for ports, this PR adds a single template level
CORS behaviour setting that is then used by all apps/ports for
workspaces created from that template.
The main changes are in `proxy.go` and `request.go` to:
a) get the CORS behaviour setting from the template
b) have `HandleSubdomain` bypass the CORS middleware handler if the
selected behaviour is `passthru`
c) in `proxyWorkspaceApp`, do not modify the response if the selected
behaviour is `passthru`
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added support for configuring CORS behavior ("simple" or "passthru")
at the template level for all shared ports.
* Introduced a new "CORS Behavior" setting in the template creation and
settings forms.
* API endpoints and responses now include the optional `cors_behavior`
property for templates.
* Workspace apps and proxy now honor the specified CORS behavior,
enabling conditional CORS middleware application.
* Enhanced workspace app tests with comprehensive scenarios covering
CORS behaviors and authentication states.
* **Bug Fixes**
* None.
* **Documentation**
* Updated API and admin documentation to describe the new
`cors_behavior` property and its usage.
* Added examples and schema references for CORS behavior in relevant API
docs.
* **Tests**
* Extended automated tests to cover different CORS behavior scenarios
for templates and workspace apps.
* **Chores**
* Updated audit logging to track changes to the `cors_behavior` field on
templates.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Signed-off-by: Callum Styan <callumstyan@gmail.com>
This change replaces date-fns with dayjs throughout the codebase for
more consistent date/time handling and to reduce bundle size. It also
tries to make the formatting and usage consistent.
**Why dayjs over date-fns?**
Just because we were using dayjs more broadly. Its formatting
capabilities, were also easier to extend.
- Add deleted column to organizations table
- Add trigger to check for existing workspaces, templates, groups and
members in a org before allowing the soft delete
---------
Co-authored-by: Steven Masley <stevenmasley@gmail.com>
Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
Using `go run` inside of a test is fragile, because it means we have to
wait for `go` to compile the binary while also constrained on resources
by the fact that Playwright and coderd are already running. We should
instead compile a coder binary for the current platform before the tests
and use it directly.
This is a significant PR that will impact many parts of the UI, so I’d
like to ask you, @jaaydenh, for a very thorough review of the Storybook
stories on Chromatic. I know it’s a bit of a hassle with around 180
stories affected, but it’s all for a good cause 💪
Main changes:
- Update the `Button` component to match the [new buttons
design](https://www.figma.com/design/WfqIgsTFXN2BscBSSyXWF8/Coder-kit?node-id=3-1756&p=f&m=dev).
- Update forms and dialogs to use the new `Button` component.
Related to https://github.com/coder/coder/issues/14978