Skip to content

Commit 6b26d8b

Browse files
committed
fix: drain in-flight preview chain when closing themes:preview
The new add/unlink listeners in this PR mean fs cleanup at the end of a preview tests (rmSync on the watched dir) fires chokidar events that schedule a runPreview() the test never awaits. afterEach restored the fetch stub and called server.close() (fire-and-forget) while runPreview was still mid-flight; once unstubbed it issued a real fetch that hung the Linux event loop until GitHub Actions cancelled the job. Track the in-flight preview promise and await it in close() after the watcher has stopped emitting. Replace the recursive rerun trampoline with a do-while loop so a single tracked promise covers the full chain. Also fixes the same teardown leak when users Ctrl-C the command. Also makes the serialize-uploads preview test deterministic by gating the stubbed upload on a test-controlled resolver rather than asserting upload counts against real chokidar fs events.
1 parent 12a176c commit 6b26d8b

2 files changed

Lines changed: 54 additions & 28 deletions

File tree

packages/zcli-themes/src/commands/themes/preview.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -101,29 +101,32 @@ export default class Preview extends Command {
101101

102102
let previewing = false
103103
let rerun = false
104+
let pendingRun: Promise<void> | undefined
104105

105106
const runPreview = async () => {
106-
previewing = true
107-
rerun = false
108-
try {
109-
await preview(themePath, flags)
110-
wss.clients.forEach((client) => {
111-
if (client.readyState === WebSocket.OPEN) {
112-
client.send('reload')
113-
}
114-
})
115-
} catch (e) {
116-
this.error(e as Error, { exit: false })
117-
} finally {
118-
previewing = false
119-
if (rerun) runPreview()
120-
}
107+
do {
108+
previewing = true
109+
rerun = false
110+
try {
111+
await preview(themePath, flags)
112+
wss.clients.forEach((client) => {
113+
if (client.readyState === WebSocket.OPEN) {
114+
client.send('reload')
115+
}
116+
})
117+
} catch (e) {
118+
this.error(e as Error, { exit: false })
119+
} finally {
120+
previewing = false
121+
}
122+
} while (rerun)
123+
pendingRun = undefined
121124
}
122125

123126
const handleThemeChange = (path: string) => {
124127
this.log(chalk.bold('Change'), path)
125128
if (previewing) rerun = true
126-
else runPreview()
129+
else pendingRun = runPreview()
127130
}
128131

129132
const watcher = chokidar.watch(monitoredPaths, { ignoreInitial: true })
@@ -132,9 +135,17 @@ export default class Preview extends Command {
132135
.on('unlink', handleThemeChange)
133136

134137
return {
135-
close: () => {
136-
// Stop watching file changes before terminating the server
137-
watcher.close()
138+
close: async () => {
139+
// Wait for the watcher to flush queued events, then drain any
140+
// in-flight preview chain so pending uploads settle before the
141+
// server shuts down. Without this, fs cleanup in tests (or a user's
142+
// editor) can fire late events whose runPreview() outlives close().
143+
await watcher.close()
144+
if (pendingRun) {
145+
// runPreview() reports its own errors via this.error; we just
146+
// need to wait for the chain to settle so close() can proceed.
147+
try { await pendingRun } catch { /* already handled */ }
148+
}
138149
server.close()
139150
wss.close()
140151
}

packages/zcli-themes/tests/functional/preview.test.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ describe('themes:preview', function () {
2121
})
2222

2323
describe('successful preview', () => {
24-
let server: { close: () => void }
24+
let server: { close: () => Promise<void> }
2525

2626
const preview = test
2727
.stdout()
@@ -40,8 +40,8 @@ describe('themes:preview', function () {
4040
server = await PreviewCommand.run([baseThemePath, '--bind', '0.0.0.0', '--port', '9999'])
4141
})
4242

43-
afterEach(() => {
44-
server.close()
43+
afterEach(async () => {
44+
await server.close()
4545
})
4646

4747
preview
@@ -134,20 +134,35 @@ describe('themes:preview', function () {
134134
preview
135135
.it('should serialize rapid successive changes instead of uploading concurrently', async () => {
136136
await waitForWatcherReady()
137+
// Gate each upload on a test-controlled resolver so assertions don't
138+
// depend on how the OS's chokidar backend coalesces fs events.
139+
const pendingResolvers: Array<() => void> = []
140+
fetchStub.withArgs(sinon.match({
141+
url: 'https://z3ntest.zendesk.com/hc/api/internal/theming/local_preview',
142+
method: 'PUT'
143+
})).callsFake(() => new Promise(resolve => {
144+
pendingResolvers.push(() => resolve({
145+
status: 200,
146+
ok: true,
147+
text: () => Promise.resolve('')
148+
}))
149+
}))
137150
const initialCalls = previewUploadCalls()
138151
const partialsDir = path.join(baseThemePath, 'templates/partials')
139152
fs.mkdirSync(partialsDir, { recursive: true })
140153
const paths = ['a.hbs', 'b.hbs', 'c.hbs'].map(name => path.join(partialsDir, name))
141154
try {
142155
for (const p of paths) fs.writeFileSync(p, '<div/>')
143156
await waitForUploadCount(initialCalls + 1)
144-
// Let any queued rerun finish; a working serializer produces at
145-
// most one in-flight upload plus one queued rerun.
146-
await new Promise(resolve => setTimeout(resolve, 500))
147-
const calls = previewUploadCalls() - initialCalls
148-
expect(calls).to.be.greaterThan(0)
149-
expect(calls).to.be.at.most(2)
157+
expect(previewUploadCalls() - initialCalls).to.eq(1)
158+
pendingResolvers.shift()?.()
159+
await waitForUploadCount(initialCalls + 2)
160+
pendingResolvers.shift()?.()
161+
await new Promise(resolve => setTimeout(resolve, 100))
162+
expect(previewUploadCalls() - initialCalls).to.eq(2)
150163
} finally {
164+
// Drain pending uploads so server.close() in afterEach isn't blocked.
165+
pendingResolvers.forEach(fn => fn())
151166
fs.rmSync(partialsDir, { recursive: true, force: true })
152167
}
153168
})

0 commit comments

Comments
 (0)