Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,17 @@ jobs:
strategy:
fail-fast: false
matrix:
# Fork: linux only. Upstream also runs this matrix on Windows, but on a
# dedicated Blacksmith runner; GitHub-hosted windows-latest runs the
# Playwright suite roughly 4x slower, so it never finished inside the
# 30-minute step budget and failed ~20 specs on 10s expect timeouts plus
# sub-pixel layout assertions that depend on the runner's font metrics.
# These specs exercise a browser SPA against a mocked server, so
# Chromium — not the host OS — is what they cover. Unit tests still run
# on Windows, where the real platform differences live.
settings:
- name: linux
host: ubuntu-latest
- name: windows
host: windows-latest
runs-on: ${{ matrix.settings.host }}
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
Expand Down
7 changes: 5 additions & 2 deletions DIVERGENCES.md

Large diffs are not rendered by default.

44 changes: 25 additions & 19 deletions packages/app/e2e/regression/session-todo-dock-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ test("animates todo lifecycle without replaying it across session tabs", async (
await switchSession(page, otherID, otherTitle)
await expect(dock).toHaveCount(0)

const returningOpen = sampleDock(page, 700)
// Bounded by the dock reappearing, not by wall clock: on a loaded runner the
// tab switch alone outlasts a fixed window, leaving nothing to assert on.
const returningOpen = sampleDock(page, 15_000, true)
await switchSession(page, sourceID, sourceTitle)
const openSamples = (await returningOpen).filter((sample) => sample.present)
expect(openSamples.length).toBeGreaterThan(0)
Expand All @@ -100,7 +102,7 @@ test("animates todo lifecycle without replaying it across session tabs", async (
events.push(todoEvent(sourceID, []))

await switchSession(page, otherID, otherTitle)
const returningEmpty = sampleDock(page, 700)
const returningEmpty = sampleDock(page, 3_000)
await switchSession(page, sourceID, sourceTitle)
await expect(dock).toHaveCount(0)
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
Expand Down Expand Up @@ -166,21 +168,25 @@ async function switchSession(page: Page, sessionID: string, title: string) {
await expectSessionTitle(page, title)
}

function sampleDock(page: Page, duration: number) {
return page.evaluate(async (duration) => {
const samples: { present: boolean; height: number; opacity: number }[] = []
const start = performance.now()
while (performance.now() - start < duration) {
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
const clip = dock?.parentElement?.parentElement
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
samples.push({
present: !!dock,
height: clip?.getBoundingClientRect().height ?? 0,
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
})
await new Promise(requestAnimationFrame)
}
return samples
}, duration)
function sampleDock(page: Page, duration: number, stopWhenPresent = false) {
return page.evaluate(
async ({ duration, stopWhenPresent }) => {
const samples: { present: boolean; height: number; opacity: number }[] = []
const start = performance.now()
while (performance.now() - start < duration) {
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
const clip = dock?.parentElement?.parentElement
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
samples.push({
present: !!dock,
height: clip?.getBoundingClientRect().height ?? 0,
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
})
if (stopWhenPresent && dock) break
await new Promise(requestAnimationFrame)
}
return samples
},
{ duration, stopWhenPresent },
)
}
17 changes: 17 additions & 0 deletions packages/app/happydom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@ import { GlobalRegistrator } from "@happy-dom/global-registrator"

GlobalRegistrator.register()

// happy-dom keeps each MutationObserver's internal mutation callback only in a
// WeakRef and unregisters the listener as soon as it is collected, so a GC
// between observe() and the mutation silently stops the observer. Real browsers
// hold it strongly; pin the callbacks for the life of the test process so
// observer-backed code isn't at the mercy of when the runner decides to collect.
const mutationListenersKey = Object.getOwnPropertySymbols(document.body).find(
(symbol) => symbol.description === "mutationListeners",
)
const retainedMutationCallbacks = new Set<unknown>()
const originalObserve = MutationObserver.prototype.observe
MutationObserver.prototype.observe = function (target: Node, options?: MutationObserverInit) {
originalObserve.call(this, target, options)
if (!mutationListenersKey) return
const listeners: { callback?: WeakRef<object> }[] = (target as never)[mutationListenersKey] ?? []
for (const listener of listeners) retainedMutationCallbacks.add(listener.callback?.deref())
}

const originalGetContext = HTMLCanvasElement.prototype.getContext
// @ts-expect-error - we're overriding with a simplified mock
HTMLCanvasElement.prototype.getContext = function (contextType: string, _options?: unknown) {
Expand Down
25 changes: 25 additions & 0 deletions packages/app/src/happydom-mutation-observer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Guards the MutationObserver patch in ../happydom.ts. happy-dom holds the
// observer's internal callback in a WeakRef, so without the patch a GC between
// observe() and the mutation silently unregisters the observer — which is how
// observer-backed timeline tests failed only on Windows CI runners.
import { expect, test } from "bun:test"

test("mutation observers survive a collection between observe and the mutation", async () => {
const route = document.createElement("section")
document.body.append(route)
const batches: number[] = []
const observer = new MutationObserver((records) => batches.push(records.length))
observer.observe(document.body, { childList: true, subtree: true })

Bun.gc(true)
await new Promise((resolve) => setTimeout(resolve, 0))
Bun.gc(true)

route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))

expect(batches.length).toBeGreaterThan(0)
observer.disconnect()
route.remove()
})
10 changes: 7 additions & 3 deletions packages/core/test/util/flock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ describe("util.flock", () => {
const key = "flock:stress"
const n = 16

// No staleMs override (60s default, like the effect sibling): this asserts
// mutual exclusion, not stale recovery — the stale-break paths have their own
// tests below. A 1s staleness bound made a legitimate break look like a
// violation whenever 16 contending processes starved an owner's 333ms
// heartbeat past the bound, which is routine on a loaded Windows runner.
const out = await Promise.all(
Array.from({ length: n }, () =>
run({
Expand All @@ -130,8 +135,7 @@ describe("util.flock", () => {
done,
active,
holdMs: 30,
staleMs: 1_000,
timeoutMs: 15_000,
timeoutMs: 30_000,
}),
),
)
Expand All @@ -144,7 +148,7 @@ describe("util.flock", () => {
.map((x) => x.trim())
.filter(Boolean)
expect(lines.length).toBe(n)
}, 20_000)
}, 60_000)

test("times out while waiting when lock is still healthy", async () => {
await using tmp = await tmpdir()
Expand Down
16 changes: 8 additions & 8 deletions packages/opencode/test/cli/run/run-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe("codev run (non-interactive subprocess)", () => {
opencode.expectExit(result, 0)
expect(result.stdout).toBe("hello from the test llm\n")
}),
60_000,
120_000,
)

cliIt.concurrent(
Expand Down Expand Up @@ -60,7 +60,7 @@ describe("codev run (non-interactive subprocess)", () => {
opencode.expectExit(plain, 0)
expect(plain.stdout).toBe("visible\n")
}),
60_000,
180_000,
)

// Regression for #27371: an unknown model used to hang the process forever
Expand All @@ -74,12 +74,12 @@ describe("codev run (non-interactive subprocess)", () => {
Effect.gen(function* () {
const result = yield* opencode.run("say hi", {
model: "test/nonexistent-model",
timeoutMs: 30_000,
timeoutMs: 60_000,
})
expect(result.exitCode).not.toBe(0)
expect(result.durationMs).toBeLessThan(30_000)
expect(result.durationMs).toBeLessThan(60_000)
}),
60_000,
120_000,
)

// The test provider's SSE error item is interpreted by the SDK as an unknown
Expand Down Expand Up @@ -138,7 +138,7 @@ describe("codev run (non-interactive subprocess)", () => {
.every((line) => line.length > 0),
).toBe(true)
}),
60_000,
120_000,
)

cliIt.concurrent(
Expand All @@ -161,7 +161,7 @@ describe("codev run (non-interactive subprocess)", () => {
})
expect(result.stdout.split("\n").filter(Boolean)).toHaveLength(1)
}),
30_000,
120_000,
)

cliIt.concurrent(
Expand Down Expand Up @@ -314,7 +314,7 @@ describe("codev run (non-interactive subprocess)", () => {
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toContain("Cannot attach local directory without a shared filesystem")
}),
30_000,
120_000,
)

cliIt.live(
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/test/lib/cli-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,10 @@ export function withCliFixture<A, E>(

const spawn = Effect.fn("opencode.spawn")(function* (args: string[], opts?: SpawnOpts) {
const start = Date.now()
const timeoutMs = opts?.timeoutMs ?? 30_000
// 60s, not 30s: every spawn pays the CLI's cold transpile, and the
// GitHub-hosted runners this fork uses run these concurrent subprocess
// tests well past 30s under load. Headroom only — a hang still exceeds it.
const timeoutMs = opts?.timeoutMs ?? 60_000
// stdin: "ignore" so the child doesn't see a piped stdin and block
// on `Bun.stdin.text()` (see src/cli/cmd/run.ts — non-TTY stdin is
// consumed as the prompt). The old Process.run wrapper defaulted to
Expand Down
Loading