test(core): make Windows-flaky shell and npm tests deterministic

This commit is contained in:
Kit Langton
2026-09-01 20:45:14 -04:00
committed by GitHub
Unverified
parent 5ee7f19875
commit 7f2645a8f4
3 changed files with 139 additions and 61 deletions
+40 -32
View File
@@ -233,35 +233,32 @@ describe("Npm.add", () => {
expect(entries.fallback.entrypoint).toEndWith("/index.js")
})
test("installs and resolves named and unnamed Git packages with dependencies", async () => {
test.each(["unnamed", "named"])("installs and resolves %s Git packages with dependencies", async (kind) => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
const specs = [
`git+file://${fixture.repository}#${fixture.commit}`,
`fixture-named-plugin@git+file://${fixture.repository}#fixture-branch`,
]
const spec =
kind === "unnamed"
? `git+file://${fixture.repository}#${fixture.commit}`
: `fixture-named-plugin@git+file://${fixture.repository}#fixture-branch`
for (const spec of specs) {
const entries = await Effect.gen(function* () {
const npm = yield* Npm.Service
return {
added: yield* npm.add(spec),
cached: yield* npm.add(spec),
resolved: yield* npm.resolve(spec),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
const entries = await Effect.gen(function* () {
const npm = yield* Npm.Service
return {
added: yield* npm.add(spec),
cached: yield* npm.add(spec),
resolved: yield* npm.resolve(spec),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entries.added.entrypoint).toBe(pathToFileURL(path.join(entries.added.directory, "index.js")).href)
expect(entries.added.version).toBe(fixture.commit)
expect(entries.cached).toEqual(entries.added)
expect(entries.resolved).toEqual(entries.added)
expect(
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
).toBeTruthy()
expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec)))
expect(entries.added.directory).toContain("node_modules")
}
expect(entries.added.entrypoint).toBe(pathToFileURL(path.join(entries.added.directory, "index.js")).href)
expect(entries.added.version).toBe(fixture.commit)
expect(entries.cached).toEqual(entries.added)
expect(entries.resolved).toEqual(entries.added)
expect(
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
).toBeTruthy()
expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec)))
expect(entries.added.directory).toContain("node_modules")
})
test("installs a Git package from an npm ::path: subdirectory", async () => {
@@ -354,23 +351,19 @@ describe("Npm.resolve", () => {
})
describe("Npm.check and Npm.update", () => {
test("checks registry targets without mutation and explicitly updates mutable targets", async () => {
test("checks a mutable registry target without mutation and explicitly updates it", async () => {
await using tmp = await tmpdir()
await using registry = await createRegistryFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
const mutable = "@fixture/registry-plugin@latest"
const pinned = "@fixture/registry-plugin@1.0.0"
const root = await registry.configure(cache, mutable)
await registry.configure(cache, pinned)
const result = await Effect.gen(function* () {
const npm = yield* Npm.Service
const installed = yield* npm.add(mutable)
yield* npm.add(pinned)
const current = yield* npm.check(mutable)
registry.state.latest = "1.1.0"
const outdated = yield* npm.check(mutable)
const pinnedOutdated = yield* npm.check(pinned)
const before = yield* Effect.promise(() => Bun.file(path.join(installed.directory, "index.js")).text())
yield* Effect.promise(() => Promise.all([fs.mkdir(path.join(root, "1")), fs.mkdir(path.join(root, "2"))]))
const updated = yield* npm.update(mutable)
@@ -378,7 +371,6 @@ describe("Npm.check and Npm.update", () => {
return {
current,
outdated,
pinnedOutdated,
before,
after: yield* Effect.promise(() => Bun.file(path.join(updated.directory, "index.js")).text()),
version: updated.version,
@@ -391,7 +383,6 @@ describe("Npm.check and Npm.update", () => {
expect(result.current).toBeFalse()
expect(result.outdated).toBeTrue()
expect(result.pinnedOutdated).toBeFalse()
expect(result.before).toContain('version = "1.0.0"')
expect(result.after).toContain('version = "1.1.0"')
expect(result.version).toBe("1.1.0")
@@ -401,4 +392,21 @@ describe("Npm.check and Npm.update", () => {
expect(result.generations).not.toContain("2")
expect(result.updated).toBeFalse()
})
test("never reports a pinned registry target as outdated", async () => {
await using tmp = await tmpdir()
await using registry = await createRegistryFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
const pinned = "@fixture/registry-plugin@1.0.0"
await registry.configure(cache, pinned)
const outdated = await Effect.gen(function* () {
const npm = yield* Npm.Service
yield* npm.add(pinned)
registry.state.latest = "1.1.0"
return yield* npm.check(pinned)
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(outdated).toBeFalse()
})
})
@@ -0,0 +1,99 @@
import { expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Queue, Scope, Sink, Stream } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Config } from "@opencode-ai/core/config"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { Shell } from "@opencode-ai/core/shell"
import { Global } from "@opencode-ai/util/global"
import { hostEnvironmentLayer } from "./fixture/environment"
import { tempGlobalLayer } from "./fixture/global"
import { tempLocationLayer } from "./fixture/location"
import { it } from "./lib/effect"
it.live("eviction makes progress past an already-removed shell", () =>
Effect.gen(function* () {
const completions = yield* Queue.unbounded<Effect.Effect<void>>()
const environment = Layer.effect(
Environment.Service,
Effect.gen(function* () {
const host = yield* Environment.Service
const scope = yield* Scope.Scope
return Environment.Service.of({
...host,
spawner: ChildProcessSpawner.make(() =>
Effect.gen(function* () {
const exited = yield* Deferred.make<ExitCode>()
const observer = yield* Deferred.make<Fiber.Fiber<unknown, unknown>>()
yield* Queue.offer(
completions,
Effect.gen(function* () {
yield* Deferred.succeed(exited, ExitCode(0))
// Shell.wait resolves before retention runs; join the whole exit handler instead.
const fiber = yield* Deferred.await(observer)
yield* Fiber.join(fiber).pipe(Effect.orDie)
}),
)
const output = Stream.succeed(Buffer.from("hello"))
return makeHandle({
pid: ProcessId(1),
exitCode: Effect.withFiber((fiber) =>
Scope.addFinalizer(scope, Fiber.interrupt(fiber)).pipe(
Effect.andThen(Deferred.succeed(observer, fiber)),
Effect.andThen(Deferred.await(exited)),
),
),
isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)),
kill: () => Deferred.succeed(exited, ExitCode(0)).pipe(Effect.asVoid),
stdin: Sink.drain,
stdout: output,
stderr: Stream.empty,
all: output,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
unref: Effect.succeed(Effect.void),
})
}),
),
})
}),
).pipe(Layer.provide(hostEnvironmentLayer))
yield* Effect.gen(function* () {
const shell = yield* Shell.Service
const removed = yield* shell.create({ shell: "sh", command: "removed", timeout: 0 })
const finishRemoved = yield* Queue.take(completions)
yield* shell.remove(removed.id)
expect((yield* shell.result(removed)).capture).toBeUndefined()
yield* finishRemoved
const complete = Effect.gen(function* () {
const info = yield* shell.create({ shell: "sh", command: "hello", timeout: 0 })
const finish = yield* Queue.take(completions)
yield* finish
return info
})
const oldest = yield* complete
// Exceed the 25-entry retention cap with the removed ID at the head of exitOrder.
yield* Effect.forEach(Array.from({ length: 25 }), () => complete, { discard: true })
expect(yield* shell.get(oldest.id).pipe(Effect.flip)).toBeInstanceOf(Shell.NotFoundError)
const survivor = yield* complete
expect(yield* shell.result(survivor)).toMatchObject({
info: { status: "exited", exit: 0 },
capture: { output: "hello", truncated: false },
})
}).pipe(
Effect.provide(
AppNodeBuilder.build(Shell.node, [
Location.node.replace(tempLocationLayer),
Global.node.replace(tempGlobalLayer),
Config.node.replace(Config.testLayer()),
Environment.node.replace(environment),
]),
),
)
}),
)
-29
View File
@@ -1510,35 +1510,6 @@ describe("ShellTool", () => {
{ timeout: 30_000 },
)
it.live("does not retain removed running shells in exit order", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
withSession(tmp.path, () =>
Effect.gen(function* () {
const shell = yield* Shell.Service
yield* Effect.forEach(Array.from({ length: 26 }), () =>
Effect.gen(function* () {
const info = yield* shell.create({ command: idleCommand, timeout: 0 })
yield* shell.remove(info.id)
expect((yield* shell.result(info)).capture).toBeUndefined()
yield* Effect.sleep(Duration.millis(10))
}),
)
const info = yield* shell.create({ command: helloCommand, timeout: 0 })
const settled = yield* shell.wait(info.id).pipe(Effect.timeoutOption(Duration.seconds(2)))
expect(settled._tag).toBe("Some")
expect(yield* shell.result(info)).toMatchObject({
info: { status: "exited", exit: 0 },
capture: { output: expect.stringContaining("hello"), truncated: false },
})
}),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
if (!isWindows) {
it.live("settles a shell terminated by an external signal", () =>
Effect.acquireUseRelease(