Skip to content

Commit a2fada5

Browse files
committed
test: add regression test for plugin event waiting
Adds test to ensure plugins complete their event handlers before opencode exits. Test verifies: - Plugin event handlers complete within timeout - Timeout mechanism works correctly Test catches the regression by failing when the event tracking or waitForPendingEvents mechanism is disabled.
1 parent 4233bea commit a2fada5

1 file changed

Lines changed: 168 additions & 0 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { afterAll, afterEach, describe, expect, test } from "bun:test"
2+
import { Effect } from "effect"
3+
import path from "path"
4+
import { pathToFileURL } from "url"
5+
import { tmpdir } from "../fixture/fixture"
6+
7+
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
8+
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
9+
10+
const { Plugin } = await import("../../src/plugin/index")
11+
const { Instance } = await import("../../src/project/instance")
12+
const { Bus } = await import("../../src/bus")
13+
const { SessionStatus } = await import("../../src/session/status")
14+
const { SessionID } = await import("../../src/session/schema")
15+
16+
afterEach(async () => {
17+
await Instance.disposeAll()
18+
})
19+
20+
afterAll(() => {
21+
if (disableDefault === undefined) {
22+
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
23+
return
24+
}
25+
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
26+
})
27+
28+
describe("plugin.waitForPendingEvents - regression test", () => {
29+
test("does not exit before async event handlers complete", async () => {
30+
await using tmp = await tmpdir({
31+
init: async (dir) => {
32+
const completionFile = path.join(dir, "completion.txt")
33+
const file = path.join(dir, "plugin.ts")
34+
35+
await Bun.write(
36+
file,
37+
[
38+
`const completionFile = ${JSON.stringify(completionFile)}`,
39+
"export default async () => ({",
40+
" event: async ({ event }) => {",
41+
" if (event.type === 'session.idle') {",
42+
" // Simulate a slow plugin operation",
43+
" await Bun.sleep(200)",
44+
" await Bun.write(completionFile, 'completed')",
45+
" }",
46+
" },",
47+
"})",
48+
"",
49+
].join("\n"),
50+
)
51+
52+
await Bun.write(
53+
path.join(dir, "opencode.json"),
54+
JSON.stringify(
55+
{
56+
$schema: "https://opencode.ai/config.json",
57+
plugin: [pathToFileURL(file).href],
58+
},
59+
null,
60+
2,
61+
),
62+
)
63+
64+
return completionFile
65+
},
66+
})
67+
68+
await Instance.provide({
69+
directory: tmp.path,
70+
fn: async () =>
71+
Effect.gen(function* () {
72+
const plugin = yield* Plugin.Service
73+
const bus = yield* Bus.Service
74+
75+
// Initialize plugin
76+
yield* plugin.init()
77+
78+
// Give plugin time to set up event subscriptions
79+
yield* Effect.sleep("50 millis")
80+
81+
// Publish session.idle event
82+
yield* bus.publish(SessionStatus.Event.Idle, {
83+
sessionID: SessionID.make("test-session"),
84+
})
85+
86+
// Wait for pending events (this is the functionality we're testing)
87+
yield* plugin.waitForPendingEvents(1000)
88+
89+
// Check if the plugin completed
90+
const fileExists = yield* Effect.tryPromise({
91+
try: () => Bun.file(tmp.extra).exists(),
92+
catch: () => false,
93+
})
94+
95+
// The completion file should exist if waitForPendingEvents worked
96+
expect(fileExists).toBe(true)
97+
98+
if (fileExists) {
99+
const content = yield* Effect.promise(() => Bun.file(tmp.extra).text())
100+
expect(content).toBe("completed")
101+
}
102+
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.provide(Bus.layer), Effect.runPromise),
103+
})
104+
})
105+
106+
test("respects timeout and doesn't wait forever", async () => {
107+
await using tmp = await tmpdir({
108+
init: async (dir) => {
109+
const file = path.join(dir, "plugin.ts")
110+
111+
await Bun.write(
112+
file,
113+
[
114+
"export default async () => ({",
115+
" event: async ({ event }) => {",
116+
" if (event.type === 'session.idle') {",
117+
" // This should timeout",
118+
" await Bun.sleep(500)",
119+
" }",
120+
" },",
121+
"})",
122+
"",
123+
].join("\n"),
124+
)
125+
126+
await Bun.write(
127+
path.join(dir, "opencode.json"),
128+
JSON.stringify(
129+
{
130+
$schema: "https://opencode.ai/config.json",
131+
plugin: [pathToFileURL(file).href],
132+
},
133+
null,
134+
2,
135+
),
136+
)
137+
},
138+
})
139+
140+
await Instance.provide({
141+
directory: tmp.path,
142+
fn: async () =>
143+
Effect.gen(function* () {
144+
const plugin = yield* Plugin.Service
145+
const bus = yield* Bus.Service
146+
147+
yield* plugin.init()
148+
149+
// Give plugin time to set up event subscriptions
150+
yield* Effect.sleep("50 millis")
151+
152+
yield* bus.publish(SessionStatus.Event.Idle, {
153+
sessionID: SessionID.make("test-session"),
154+
})
155+
156+
const start = Date.now()
157+
// Use a short timeout (100ms) - plugin sleeps for 500ms
158+
yield* plugin.waitForPendingEvents(100)
159+
const elapsed = Date.now() - start
160+
161+
// Should timeout around 100ms, not wait for the full 500ms
162+
// Allow 150ms margin for timing variance
163+
expect(elapsed).toBeGreaterThan(50)
164+
expect(elapsed).toBeLessThan(250)
165+
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.provide(Bus.layer), Effect.runPromise),
166+
})
167+
})
168+
})

0 commit comments

Comments
 (0)