@dimina-kit/devkit 0.1.2-dev.20260612025610 → 0.1.2-dev.20260615070430

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +5 -3
  2. package/dist/compile-log.d.ts +16 -0
  3. package/dist/compile-log.d.ts.map +1 -0
  4. package/dist/compile-log.js +42 -0
  5. package/dist/compile-log.test.d.ts +2 -0
  6. package/dist/compile-log.test.d.ts.map +1 -0
  7. package/dist/compile-log.test.js +134 -0
  8. package/dist/compile-worker-entry.d.ts +47 -0
  9. package/dist/compile-worker-entry.d.ts.map +1 -0
  10. package/dist/compile-worker-entry.js +117 -0
  11. package/dist/compile-worker-entry.test.d.ts +2 -0
  12. package/dist/compile-worker-entry.test.d.ts.map +1 -0
  13. package/dist/compile-worker-entry.test.js +247 -0
  14. package/dist/compile-worker-leak.test.d.ts +2 -0
  15. package/dist/compile-worker-leak.test.d.ts.map +1 -0
  16. package/dist/compile-worker-leak.test.js +284 -0
  17. package/dist/compile-worker.d.ts +33 -0
  18. package/dist/compile-worker.d.ts.map +1 -0
  19. package/dist/compile-worker.js +213 -0
  20. package/dist/compile-worker.test.d.ts +2 -0
  21. package/dist/compile-worker.test.d.ts.map +1 -0
  22. package/dist/compile-worker.test.js +791 -0
  23. package/dist/index.d.ts +15 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +100 -31
  26. package/dist/open-project-cleanup.test.d.ts +2 -0
  27. package/dist/open-project-cleanup.test.d.ts.map +1 -0
  28. package/dist/open-project-cleanup.test.js +176 -0
  29. package/dist/open-project-compile-log.test.d.ts +2 -0
  30. package/dist/open-project-compile-log.test.d.ts.map +1 -0
  31. package/dist/open-project-compile-log.test.js +174 -0
  32. package/dist/rebuild-scheduler.d.ts +24 -0
  33. package/dist/rebuild-scheduler.d.ts.map +1 -0
  34. package/dist/rebuild-scheduler.js +58 -0
  35. package/dist/rebuild-scheduler.test.d.ts +2 -0
  36. package/dist/rebuild-scheduler.test.d.ts.map +1 -0
  37. package/dist/rebuild-scheduler.test.js +201 -0
  38. package/dist/watch-rebuild.testutil.d.ts +21 -0
  39. package/dist/watch-rebuild.testutil.d.ts.map +1 -0
  40. package/dist/watch-rebuild.testutil.js +81 -0
  41. package/package.json +3 -3
@@ -0,0 +1,791 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { PassThrough } from 'node:stream';
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import * as devkit from './index.js';
8
+ import { writeUntilPredicate, writeUntilSettled } from './watch-rebuild.testutil.js';
9
+ /**
10
+ * FLAKE HARDENING (no assertions changed): the watcher-driven rebuild tests
11
+ * below depend on a REAL chokidar inotify watch (fork is mocked, chokidar is
12
+ * not). Under CI load (concurrent real dmcc compile in
13
+ * open-project-compile-log.test.ts pegging CPU) a single fs.writeFileSync's
14
+ * inotify event can be dropped, hanging `await rebuilt`/`vi.waitFor` forever
15
+ * (PR #44 30s timeout at the "fork exactly once across … 2 rebuilds" test).
16
+ * Count-agnostic / `>=` waiters are wrapped in writeUntilSettled /
17
+ * writeUntilPredicate, which RE-WRITE the source file (micro-varied content →
18
+ * fresh inotify event) until the rebuild lands; the rebuild scheduler
19
+ * coalesces the extra writes into one trailing build, so the pinned outcomes
20
+ * are unchanged. The exact-count serial-IPC test deliberately does NOT use
21
+ * them (see its inline note).
22
+ */
23
+ /**
24
+ * FORK-ARCHITECTURE WAVE (dmcc 编译子进程化) — TDD contract for the PARENT
25
+ * side: `openProject` orchestrating a long-lived forked compile worker
26
+ * (NOT yet implemented).
27
+ *
28
+ * ⚠️ ARCHITECTURE-DECISION CHANGE (user-approved, 2026-06-12):
29
+ * Compilation moves from the in-process `require('@dimina/compiler')` call
30
+ * (src/index.ts:104-105 with its host-global `process.chdir`) into a forked
31
+ * long-lived child process. The tee-style `withCapturedStdio` contract from
32
+ * ROUND 2 was deleted (see compile-log.test.ts header); THIS file pins its
33
+ * replacement. Explicit architecture correction, not goalpost-moving.
34
+ *
35
+ * Parent-side contract pinned here (child_process.fork is mocked; the fake
36
+ * child is an EventEmitter with PassThrough stdout/stderr + send/kill spies):
37
+ * 1. `openProject` forks the compile worker EXACTLY ONCE and keeps it for
38
+ * the whole session — first compile and every watcher rebuild are
39
+ * `{ cmd: 'build', projectPath, outputDir, options }` IPC messages to
40
+ * the SAME child. Fork options must pipe stdout/stderr.
41
+ * 2. The parent process NEVER calls `process.chdir` — the core motive of
42
+ * this architecture (the worker chdirs in its own process instead).
43
+ * 3. child stdout/stderr are split into lines (with cross-chunk half-line
44
+ * buffering), passed through `filterDmccLogLine`, and only surviving
45
+ * lines reach `opts.onLog({ stream, text })`.
46
+ * 4. The worker's `{ type: 'result', appInfo }` reply becomes the resolved
47
+ * `session.appInfo` — same shape as today (downstream consumers key
48
+ * storage prefixes etc. off `appId`).
49
+ * 5. `session.close()` kills the worker.
50
+ * 6. A worker that exits unexpectedly mid-build settles the in-flight build
51
+ * via `opts.onBuildError(Error mentioning the worker)` instead of
52
+ * hanging; the NEXT rebuild re-forks a fresh worker (crash recovery).
53
+ * 7. Serial IPC: while a build command is unanswered, watcher events never
54
+ * produce a concurrent build command — they coalesce into exactly one
55
+ * trailing build (rebuild-scheduler semantics across the IPC boundary).
56
+ * 8. Without `onLog` the worker is STILL forked (fork is the uniform
57
+ * compile path, not a logging feature) and stray child output is simply
58
+ * not delivered anywhere.
59
+ *
60
+ * NOTE for the implementer: this file mocks BOTH 'node:child_process' and
61
+ * 'child_process' — import fork from either. The fake child auto-replies to
62
+ * build commands unless a test flips `autoRespond` off to control timing.
63
+ */
64
+ const mocks = vi.hoisted(() => ({ fork: vi.fn() }));
65
+ vi.mock('node:child_process', async (importOriginal) => {
66
+ const actual = await importOriginal();
67
+ return { ...actual, fork: mocks.fork, default: { ...actual, fork: mocks.fork } };
68
+ });
69
+ vi.mock('child_process', async (importOriginal) => {
70
+ const actual = await importOriginal();
71
+ return { ...actual, fork: mocks.fork, default: { ...actual, fork: mocks.fork } };
72
+ });
73
+ /** The appInfo the fake worker replies with (path is echoed per message). */
74
+ const WORKER_APP = { appId: 'worker_app_777', name: 'from-worker' };
75
+ class FakeChild extends EventEmitter {
76
+ stdout = new PassThrough();
77
+ stderr = new PassThrough();
78
+ connected = true;
79
+ killed = false;
80
+ pid = 4242;
81
+ /** When true (default), every {cmd:'build'} is answered on a microtask. */
82
+ autoRespond = true;
83
+ /**
84
+ * When true, kill() does NOT auto-emit 'exit' — the test simulates the
85
+ * child death manually (or never). Default false keeps every pre-existing
86
+ * test's behaviour byte-identical.
87
+ */
88
+ manualExit = false;
89
+ sent = [];
90
+ send = vi.fn((msg) => {
91
+ const m = msg;
92
+ this.sent.push(m);
93
+ if (this.autoRespond && m && m.cmd === 'build') {
94
+ queueMicrotask(() => {
95
+ if (!this.connected)
96
+ return;
97
+ this.emit('message', {
98
+ type: 'result',
99
+ appInfo: { ...WORKER_APP, path: String(m.projectPath ?? '') },
100
+ });
101
+ });
102
+ }
103
+ return true;
104
+ });
105
+ kill = vi.fn((..._args) => {
106
+ this.killed = true;
107
+ this.connected = false;
108
+ if (!this.manualExit)
109
+ queueMicrotask(() => this.emit('exit', null, 'SIGTERM'));
110
+ return true;
111
+ });
112
+ buildSends() {
113
+ return this.sent.filter(m => m && m.cmd === 'build');
114
+ }
115
+ /** Manually answer the in-flight build (for autoRespond=false tests). */
116
+ respondToBuild(projectPath) {
117
+ this.emit('message', {
118
+ type: 'result',
119
+ appInfo: { ...WORKER_APP, path: projectPath },
120
+ });
121
+ }
122
+ /** Simulate an unexpected worker death. */
123
+ crash(code = 1) {
124
+ this.connected = false;
125
+ this.emit('exit', code, null);
126
+ }
127
+ }
128
+ const children = [];
129
+ const openSessions = [];
130
+ const cleanupRoots = [];
131
+ beforeEach(() => {
132
+ children.length = 0;
133
+ mocks.fork.mockReset();
134
+ mocks.fork.mockImplementation(() => {
135
+ const child = new FakeChild();
136
+ children.push(child);
137
+ return child;
138
+ });
139
+ });
140
+ afterEach(async () => {
141
+ for (const session of openSessions.splice(0)) {
142
+ try {
143
+ await session.close();
144
+ }
145
+ catch {
146
+ // best-effort teardown
147
+ }
148
+ }
149
+ for (const root of cleanupRoots.splice(0)) {
150
+ fs.rmSync(root, { recursive: true, force: true });
151
+ }
152
+ });
153
+ function makeFixture() {
154
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-compile-worker-'));
155
+ cleanupRoots.push(root);
156
+ const write = (rel, content) => {
157
+ const target = path.join(root, rel);
158
+ fs.mkdirSync(path.dirname(target), { recursive: true });
159
+ fs.writeFileSync(target, content);
160
+ };
161
+ write('project.config.json', JSON.stringify({ appid: 'fixture_app_001', projectname: 'fixture-app' }));
162
+ write('app.json', JSON.stringify({ pages: ['pages/index/index'] }));
163
+ write('app.js', 'App({})\n');
164
+ write('app.wxss', 'page { font-size: 14px; }\n');
165
+ write('pages/index/index.json', '{}\n');
166
+ write('pages/index/index.js', 'Page({ data: { msg: "hi" } })\n');
167
+ write('pages/index/index.wxml', '<view>{{msg}}</view>\n');
168
+ write('pages/index/index.wxss', '.x { color: red; }\n');
169
+ return root;
170
+ }
171
+ function theChild() {
172
+ const child = children.at(-1);
173
+ expect(child, 'openProject must fork the compile worker — child_process.fork was never called').toBeDefined();
174
+ return child;
175
+ }
176
+ function rebuildWaiter() {
177
+ const waiters = [];
178
+ return {
179
+ onRebuild: () => {
180
+ for (const wake of waiters.splice(0))
181
+ wake();
182
+ },
183
+ next: () => new Promise(resolve => waiters.push(resolve)),
184
+ };
185
+ }
186
+ function sleep(ms) {
187
+ return new Promise(resolve => setTimeout(resolve, ms));
188
+ }
189
+ describe('openProject — fork-based compile worker orchestration', () => {
190
+ it('forks the compile worker exactly once, targeting the compile-worker-entry module with piped stdio', async () => {
191
+ const root = makeFixture();
192
+ const session = await devkit.openProject({
193
+ projectPath: root,
194
+ watch: false,
195
+ outputDir: path.join(root, '.out'),
196
+ });
197
+ openSessions.push(session);
198
+ expect(mocks.fork, 'openProject must fork the long-lived compile worker via child_process.fork — compilation no longer runs in the host process').toHaveBeenCalledTimes(1);
199
+ const call = mocks.fork.mock.calls[0];
200
+ expect(String(call[0]), 'the fork target must be the compile-worker-entry module').toContain('compile-worker-entry');
201
+ // Without piped stdio the parent can never read dmcc output: either
202
+ // silent:true or an explicit stdio array containing 'pipe' is required.
203
+ const optionsArg = call.find(arg => typeof arg === 'object' && arg !== null && !Array.isArray(arg));
204
+ let piped = false;
205
+ if (optionsArg) {
206
+ piped = optionsArg.silent === true
207
+ || (Array.isArray(optionsArg.stdio) && optionsArg.stdio.includes('pipe'));
208
+ }
209
+ expect(piped, 'fork options must pipe the child stdout/stderr (silent:true or a stdio array containing "pipe") — otherwise onLog can never see dmcc output').toBe(true);
210
+ }, 45_000);
211
+ it('first build flows over IPC and openProject resolves with the worker-returned AppInfo (downstream shape preserved)', async () => {
212
+ const root = makeFixture();
213
+ const outputDir = path.join(root, '.out');
214
+ const session = await devkit.openProject({
215
+ projectPath: root,
216
+ watch: false,
217
+ outputDir,
218
+ sourcemap: true,
219
+ });
220
+ openSessions.push(session);
221
+ const child = theChild();
222
+ const builds = child.buildSends();
223
+ expect(builds.length, 'the first compile must be a {cmd:"build"} IPC message to the worker').toBe(1);
224
+ expect(builds[0]).toEqual(expect.objectContaining({
225
+ cmd: 'build',
226
+ projectPath: root,
227
+ outputDir,
228
+ options: expect.objectContaining({ sourcemap: true }),
229
+ }));
230
+ // The fake worker replied {type:'result', appInfo} — openProject must
231
+ // resolve with exactly that appInfo. Consumers key storage prefixes
232
+ // etc. off appId; the shape must survive the IPC hop unchanged.
233
+ expect(session.appInfo).toEqual({ ...WORKER_APP, path: root });
234
+ expect(typeof session.port).toBe('number');
235
+ expect(typeof session.close).toBe('function');
236
+ }, 45_000);
237
+ it('the parent process NEVER calls process.chdir — first build and rebuild alike', async () => {
238
+ const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => { });
239
+ try {
240
+ const root = makeFixture();
241
+ const waiter = rebuildWaiter();
242
+ const session = await devkit.openProject({
243
+ projectPath: root,
244
+ watch: true,
245
+ outputDir: path.join(root, '.out'),
246
+ onRebuild: waiter.onRebuild,
247
+ // Either way the rebuild attempt finished — the pin below is
248
+ // chdir-zero, not the rebuild outcome.
249
+ onBuildError: waiter.onRebuild,
250
+ });
251
+ openSessions.push(session);
252
+ const rebuilt = waiter.next();
253
+ // Count-agnostic (chdir-zero): re-write until the rebuild actually lands.
254
+ await writeUntilSettled(rebuilt, path.join(root, 'pages', 'index', 'index.js'), attempt => `Page({ data: { msg: "updated-${attempt}" } })\n`);
255
+ expect(chdirSpy, 'process.chdir in the host (Electron main) process is the root cause this architecture kills — the parent must never chdir; the worker chdirs in its OWN process').not.toHaveBeenCalled();
256
+ }
257
+ finally {
258
+ chdirSpy.mockRestore();
259
+ }
260
+ }, 45_000);
261
+ it('rebuilds reuse the same long-lived worker: fork exactly once across first build + 2 rebuilds', async () => {
262
+ const root = makeFixture();
263
+ const waiter = rebuildWaiter();
264
+ const session = await devkit.openProject({
265
+ projectPath: root,
266
+ watch: true,
267
+ outputDir: path.join(root, '.out'),
268
+ onRebuild: waiter.onRebuild,
269
+ });
270
+ openSessions.push(session);
271
+ const child = theChild();
272
+ for (const round of [1, 2]) {
273
+ const rebuilt = waiter.next();
274
+ // Count-agnostic (fork-once / buildSends >= 3): re-write until each
275
+ // rebuild lands. Extra coalesced rebuilds cannot break a `>=` or a
276
+ // single-fork pin — fork is mocked and stays one child.
277
+ await writeUntilSettled(rebuilt, path.join(root, 'pages', 'index', 'index.js'), attempt => `Page({ data: { msg: "round-${round}-${attempt}" } })\n`);
278
+ }
279
+ expect(mocks.fork, 'the worker is LONG-LIVED: re-forking per build is the old CLI-subprocess model this contract rejects (compiler/module caches would be cold every time)').toHaveBeenCalledTimes(1);
280
+ expect(child.buildSends().length, 'first compile + 2 rebuilds must all be build commands on the SAME child').toBeGreaterThanOrEqual(3);
281
+ expect(children).toHaveLength(1);
282
+ }, 45_000);
283
+ it('child stdout/stderr lines are filtered through filterDmccLogLine and delivered to onLog with stream tags', async () => {
284
+ const root = makeFixture();
285
+ const entries = [];
286
+ const logOpts = { onLog: (entry) => entries.push(entry) };
287
+ const session = await devkit.openProject({
288
+ projectPath: root,
289
+ watch: false,
290
+ outputDir: path.join(root, '.out'),
291
+ ...logOpts,
292
+ });
293
+ openSessions.push(session);
294
+ const child = theChild();
295
+ entries.length = 0;
296
+ // keep / drop / drop on stdout; keep on stderr — verbatim spike lines.
297
+ child.stdout.write('✔ 收集配置信息\n❯ 编译页面逻辑\n› [████░░░░░░░░░░░░░░░░░░░░░░░░░░] 12.50%\n');
298
+ child.stderr.write('[compat] Unsupported wx API: wx.createInnerAudioContext (/pages/audio-test/audio-test.js:33)\n');
299
+ await vi.waitFor(() => {
300
+ expect(entries.length).toBeGreaterThanOrEqual(2);
301
+ }, { timeout: 5000 });
302
+ await sleep(50); // noise lines must not straggle in late
303
+ expect(entries).toHaveLength(2);
304
+ expect(entries).toEqual(expect.arrayContaining([
305
+ { stream: 'stdout', text: '✔ 收集配置信息' },
306
+ { stream: 'stderr', text: '[compat] Unsupported wx API: wx.createInnerAudioContext (/pages/audio-test/audio-test.js:33)' },
307
+ ]));
308
+ }, 45_000);
309
+ it('half lines split across chunks are buffered until the newline arrives (no partial-line delivery)', async () => {
310
+ const root = makeFixture();
311
+ const entries = [];
312
+ const logOpts = { onLog: (entry) => entries.push(entry) };
313
+ const session = await devkit.openProject({
314
+ projectPath: root,
315
+ watch: false,
316
+ outputDir: path.join(root, '.out'),
317
+ ...logOpts,
318
+ });
319
+ openSessions.push(session);
320
+ const child = theChild();
321
+ entries.length = 0;
322
+ child.stdout.write('✔ 收集');
323
+ await sleep(30);
324
+ child.stdout.write('配置信息\n');
325
+ child.stderr.write('[logic] esbuild 转换失败 /p/x.js: Transform fail');
326
+ await sleep(30);
327
+ child.stderr.write('ed with 1 error:\n');
328
+ await vi.waitFor(() => {
329
+ expect(entries.length).toBeGreaterThanOrEqual(2);
330
+ }, { timeout: 5000 });
331
+ await sleep(50);
332
+ expect(entries.some(entry => entry.text === '✔ 收集'), 'a half line must never be delivered — buffer it until its newline arrives').toBe(false);
333
+ expect(entries).toHaveLength(2);
334
+ expect(entries).toEqual(expect.arrayContaining([
335
+ { stream: 'stdout', text: '✔ 收集配置信息' },
336
+ { stream: 'stderr', text: '[logic] esbuild 转换失败 /p/x.js: Transform failed with 1 error:' },
337
+ ]));
338
+ }, 45_000);
339
+ it('without onLog the worker is STILL forked (uniform architecture) and stray output is simply not delivered', async () => {
340
+ const root = makeFixture();
341
+ const session = await devkit.openProject({
342
+ projectPath: root,
343
+ watch: false,
344
+ outputDir: path.join(root, '.out'),
345
+ });
346
+ openSessions.push(session);
347
+ const child = theChild();
348
+ expect(mocks.fork, 'forking is the uniform compile path — it must not be conditional on the onLog option').toHaveBeenCalledTimes(1);
349
+ // Child output with no onLog consumer must be inert (no crash, no
350
+ // unhandled error) — zero-callback contract.
351
+ child.stdout.write('✔ 收集配置信息\n');
352
+ child.stderr.write('✖ 编译页面逻辑 [FAILED: x]\n');
353
+ await sleep(80);
354
+ expect(session.appInfo.appId).toBe(WORKER_APP.appId);
355
+ }, 45_000);
356
+ it('session.close() kills the long-lived worker', async () => {
357
+ const root = makeFixture();
358
+ const session = await devkit.openProject({
359
+ projectPath: root,
360
+ watch: false,
361
+ outputDir: path.join(root, '.out'),
362
+ });
363
+ openSessions.push(session);
364
+ const child = theChild();
365
+ await session.close();
366
+ expect(child.kill, 'close() must kill the compile worker — a leaked child per closed project is the new architecture\'s one new failure mode').toHaveBeenCalled();
367
+ }, 45_000);
368
+ it('a worker crash mid-build settles the in-flight rebuild via onBuildError, and the next rebuild re-forks (crash recovery)', async () => {
369
+ const root = makeFixture();
370
+ const waiter = rebuildWaiter();
371
+ const buildErrors = [];
372
+ const session = await devkit.openProject({
373
+ projectPath: root,
374
+ watch: true,
375
+ outputDir: path.join(root, '.out'),
376
+ onRebuild: waiter.onRebuild,
377
+ onBuildError: (err) => buildErrors.push(err),
378
+ });
379
+ openSessions.push(session);
380
+ const first = theChild();
381
+ first.autoRespond = false;
382
+ // Trigger a rebuild whose build command will never be answered…
383
+ // buildSends===2 is a STABLE equality here: the child never auto-responds,
384
+ // so the first build stays in flight and every extra (re-written) watcher
385
+ // event coalesces to dirty — buildSends cannot exceed 2. Re-write until
386
+ // the (single) in-flight build command is observed.
387
+ await writeUntilPredicate(() => first.buildSends().length === 2, path.join(root, 'pages', 'index', 'index.js'), attempt => `Page({ data: { msg: "doomed-${attempt}" } })\n`);
388
+ expect(first.buildSends().length).toBe(2);
389
+ // …then kill the worker out from under it.
390
+ first.crash(1);
391
+ await vi.waitFor(() => {
392
+ expect(buildErrors.length, 'an in-flight build whose worker died must settle through onBuildError — NOT hang the rebuild scheduler forever').toBeGreaterThanOrEqual(1);
393
+ }, { timeout: 5000 });
394
+ const err = buildErrors[0];
395
+ expect(err).toBeInstanceOf(Error);
396
+ expect(String(err.message), 'the error must identify the compile worker death (so devtools can render it, not a generic failure)').toMatch(/worker/i);
397
+ // Recovery: the NEXT rebuild re-forks a fresh worker and succeeds.
398
+ // Count-agnostic (fork-times-2 after a crash): the first worker is dead, so
399
+ // re-writing only schedules trailing rebuilds on the single fresh worker.
400
+ const rebuilt = waiter.next();
401
+ await writeUntilSettled(rebuilt, path.join(root, 'pages', 'index', 'index.js'), attempt => `Page({ data: { msg: "recovered-${attempt}" } })\n`);
402
+ await vi.waitFor(() => {
403
+ expect(children.length, 'after a crash the next rebuild must fork a FRESH worker').toBe(2);
404
+ }, { timeout: 5000 });
405
+ expect(mocks.fork).toHaveBeenCalledTimes(2);
406
+ const second = children.at(-1);
407
+ expect(second.buildSends().length).toBeGreaterThanOrEqual(1);
408
+ }, 45_000);
409
+ it('a worker crash while IDLE is recovered too: the next rebuild forks a fresh worker and succeeds', async () => {
410
+ const root = makeFixture();
411
+ const waiter = rebuildWaiter();
412
+ const session = await devkit.openProject({
413
+ projectPath: root,
414
+ watch: true,
415
+ outputDir: path.join(root, '.out'),
416
+ onRebuild: waiter.onRebuild,
417
+ });
418
+ openSessions.push(session);
419
+ const first = theChild();
420
+ first.crash(1);
421
+ // Count-agnostic (fork-times-2): the crashed worker is gone, so re-writes
422
+ // only schedule trailing rebuilds on the one fresh worker — no extra fork.
423
+ const rebuilt = waiter.next();
424
+ await writeUntilSettled(rebuilt, path.join(root, 'pages', 'index', 'index.js'), attempt => `Page({ data: { msg: "after-idle-crash-${attempt}" } })\n`);
425
+ expect(mocks.fork, 'an idle crash must not wedge the session — the next rebuild re-forks exactly one fresh worker').toHaveBeenCalledTimes(2);
426
+ const second = children.at(-1);
427
+ expect(second).not.toBe(first);
428
+ expect(second.buildSends().length).toBeGreaterThanOrEqual(1);
429
+ }, 45_000);
430
+ it('serial IPC: a save during an in-flight build never sends a concurrent build command — it coalesces into one trailing build', async () => {
431
+ const root = makeFixture();
432
+ const waiter = rebuildWaiter();
433
+ const session = await devkit.openProject({
434
+ projectPath: root,
435
+ watch: true,
436
+ outputDir: path.join(root, '.out'),
437
+ onRebuild: waiter.onRebuild,
438
+ });
439
+ openSessions.push(session);
440
+ const child = theChild();
441
+ child.autoRespond = false;
442
+ const pageFile = path.join(root, 'pages', 'index', 'index.js');
443
+ // EXACT-COUNT test — the writeUntilSettled re-write helper is NOT applied
444
+ // to the coalescing assertions (===2 in-flight, ===3 after the response).
445
+ // The first waiter is a STABLE EQUALITY (===2): while no build is answered
446
+ // every extra watcher event coalesces to dirty, so re-writing to defeat a
447
+ // dropped inotify event cannot push the count past 2.
448
+ await writeUntilPredicate(() => child.buildSends().length === 2, pageFile, attempt => `Page({ data: { msg: "a-${attempt}" } })\n`);
449
+ expect(child.buildSends().length).toBe(2);
450
+ // A second save lands while the first rebuild's IPC is unanswered. Pump it
451
+ // across the in-flight window: EVERY save while a build is unanswered folds
452
+ // into ONE dirty flag (rebuild-scheduler coalescing), so the count provably
453
+ // stays 2 — no concurrent build — no matter how many we issue. Re-issuing
454
+ // thus CANNOT over-count past the single trailing build; it only defeats a
455
+ // dropped inotify event under CI CPU contention (a single mid-build write
456
+ // whose event is lost would otherwise leave dirty unset → no trailing build
457
+ // → the ===3 assertion below hangs to timeout). The ===2 invariant is the
458
+ // real serialization proof and is asserted on every pump.
459
+ for (let attempt = 0; attempt < 6; attempt++) {
460
+ fs.writeFileSync(pageFile, `Page({ data: { msg: "b-${attempt}" } })\n`);
461
+ await sleep(250);
462
+ expect(child.buildSends().length, 'NO concurrent build command while one is in flight — rebuild-scheduler serialization must hold across the IPC boundary').toBe(2);
463
+ }
464
+ // Answer the in-flight build: the coalesced dirty flag (set by the pumped
465
+ // saves above, all while the build was unanswered) yields exactly ONE
466
+ // trailing build.
467
+ child.respondToBuild(root);
468
+ await vi.waitFor(() => {
469
+ expect(child.buildSends().length, 'the save that landed mid-build must coalesce into exactly one trailing build once the in-flight one settles').toBe(3);
470
+ }, { timeout: 5000 });
471
+ // Settle everything for teardown.
472
+ child.autoRespond = true;
473
+ child.respondToBuild(root);
474
+ await sleep(50);
475
+ expect(mocks.fork).toHaveBeenCalledTimes(1);
476
+ }, 45_000);
477
+ });
478
+ /**
479
+ * LEAK-PROOFING WAVE (项目关闭时保证编译子进程同步关闭) — close-time guards on
480
+ * the exported `createCompileWorker` directly (same mocked fork as above).
481
+ * Real process death is pinned by `compile-worker-leak.test.ts`; these pin
482
+ * the parent-side STATE MACHINE around close:
483
+ * - close with a build in flight kills immediately and SETTLES the pending
484
+ * promise (a hung promise wedges the rebuild scheduler — and a scheduler
485
+ * wedged at close time is itself a teardown leak),
486
+ * - a closed worker can never be resurrected (build-after-close must not
487
+ * re-fork — a zombie re-fork after project close IS the leak).
488
+ */
489
+ describe('createCompileWorker — close-time leak guards (direct use)', () => {
490
+ it('close() with a build in flight kills the worker immediately and the pending build settles (no hang)', async () => {
491
+ // Children for this test never auto-respond: the build stays in flight.
492
+ mocks.fork.mockImplementation(() => {
493
+ const child = new FakeChild();
494
+ child.autoRespond = false;
495
+ children.push(child);
496
+ return child;
497
+ });
498
+ const worker = devkit.createCompileWorker({});
499
+ const pending = worker.build({
500
+ projectPath: '/tmp/p',
501
+ outputDir: '/tmp/out',
502
+ options: {},
503
+ });
504
+ // Pre-arm the rejection expectation so the settle is observed even if
505
+ // it happens while we are still asserting on the kill spy.
506
+ const settled = expect(pending, 'a build in flight at close() must SETTLE (reject) — a forever-pending build wedges the rebuild scheduler').rejects.toThrow(/worker|closed/i);
507
+ await vi.waitFor(() => {
508
+ expect(children.length).toBe(1);
509
+ }, { timeout: 5000 });
510
+ const child = children[0];
511
+ worker.close();
512
+ expect(child.kill, 'close() must kill the worker even while a build is in flight — waiting for the build to finish first '
513
+ + 'leaves a busy compiler running after the project closed').toHaveBeenCalled();
514
+ await settled;
515
+ }, 45_000);
516
+ it('build() after close() rejects and NEVER re-forks — a closed session must not resurrect a worker process', async () => {
517
+ const worker = devkit.createCompileWorker({});
518
+ const request = {
519
+ projectPath: '/tmp/p',
520
+ outputDir: '/tmp/out',
521
+ options: {},
522
+ };
523
+ // One normal build so the worker exists, then close it.
524
+ await worker.build(request);
525
+ expect(mocks.fork).toHaveBeenCalledTimes(1);
526
+ worker.close();
527
+ const child = children[0];
528
+ expect(child.kill).toHaveBeenCalled();
529
+ await expect(worker.build(request), 'build on a closed worker must reject loudly — silently re-forking would leak a process the session '
530
+ + 'owner already believes is gone').rejects.toThrow(/clos/i);
531
+ expect(mocks.fork, 'NO re-fork after close(): close is terminal for the instance (refill-on-close is the documented '
532
+ + 'teardown-wedge anti-pattern, and a post-close fork is an untracked orphan)').toHaveBeenCalledTimes(1);
533
+ expect(children).toHaveLength(1);
534
+ }, 45_000);
535
+ it('close() is idempotent — the second close neither throws nor double-kills', async () => {
536
+ const worker = devkit.createCompileWorker({});
537
+ await worker.build({
538
+ projectPath: '/tmp/p',
539
+ outputDir: '/tmp/out',
540
+ options: {},
541
+ });
542
+ const child = children[0];
543
+ worker.close();
544
+ expect(() => worker.close()).not.toThrow();
545
+ expect(child.kill, 'double-close must not double-kill: the second kill could land on a recycled OS PID').toHaveBeenCalledTimes(1);
546
+ }, 45_000);
547
+ });
548
+ /**
549
+ * CODEX-REVIEW REGRESSION WAVE (fix/editor-hot-reload-and-simulator-leftovers)
550
+ * — failing regression tests for review findings M1 / M2 / M3 / m7. Same
551
+ * mocked-fork harness as above; each test names the finding it pins.
552
+ *
553
+ * M1 a `{ type:'result', error }` reply is currently resolved as a SUCCESS
554
+ * with appInfo:null — it must reject the in-flight build (message passed
555
+ * through) and surface via onBuildError on the rebuild path.
556
+ * M2 a child 'error' event (spawn/IPC failure) is swallowed; Node does NOT
557
+ * guarantee an accompanying 'exit', so the in-flight build hangs forever
558
+ * and the dead child is never replaced.
559
+ * M3 close() kills and returns immediately — it must return a promise that
560
+ * resolves only after the child actually exited, and must settle the
561
+ * in-flight build itself (not rely on the child cooperating with exit).
562
+ * m7 a final line without a trailing newline is buffered forever — it must
563
+ * be flushed to onLog exactly once when the stream ends.
564
+ */
565
+ describe('codex review regressions — worker error replies, fork errors, close semantics, trailing line (M1/M2/M3/m7)', () => {
566
+ const REQUEST = {
567
+ projectPath: '/tmp/p',
568
+ outputDir: '/tmp/out',
569
+ options: {},
570
+ };
571
+ function raceSettle(promise, ms) {
572
+ return Promise.race([
573
+ promise.then(() => 'resolved', () => 'rejected'),
574
+ sleep(ms).then(() => 'hung'),
575
+ ]);
576
+ }
577
+ /** Children that never auto-respond — the build stays in flight. */
578
+ function useSilentChildren() {
579
+ mocks.fork.mockImplementation(() => {
580
+ const child = new FakeChild();
581
+ child.autoRespond = false;
582
+ children.push(child);
583
+ return child;
584
+ });
585
+ }
586
+ it('M1: build() rejects (error message passed through) when the worker reply carries an error — not a silent appInfo:null success', async () => {
587
+ useSilentChildren();
588
+ const worker = devkit.createCompileWorker({});
589
+ const pending = worker.build(REQUEST);
590
+ const settled = expect(pending, 'a worker reply carrying { error } must REJECT the in-flight build with the worker-reported message — '
591
+ + 'resolving it as appInfo:null reports a failed compile as a success').rejects.toThrow(/compiler init failed/);
592
+ await vi.waitFor(() => {
593
+ expect(children.length).toBe(1);
594
+ }, { timeout: 5000 });
595
+ const child = children[0];
596
+ child.emit('message', {
597
+ type: 'result',
598
+ appInfo: null,
599
+ error: { message: 'boom — compiler init failed' },
600
+ });
601
+ await settled;
602
+ }, 45_000);
603
+ it('M1: a rebuild whose worker reply carries an error settles through onBuildError — NOT through onRebuild as a fake hot-reload success', async () => {
604
+ const root = makeFixture();
605
+ const buildErrors = [];
606
+ const rebuilds = [];
607
+ const session = await devkit.openProject({
608
+ projectPath: root,
609
+ watch: true,
610
+ outputDir: path.join(root, '.out'),
611
+ onRebuild: () => rebuilds.push(true),
612
+ onBuildError: (err) => buildErrors.push(err),
613
+ });
614
+ openSessions.push(session);
615
+ const child = theChild();
616
+ child.autoRespond = false;
617
+ // buildSends===2 is a stable equality (child never auto-responds, so the
618
+ // first build stays in flight and re-written watcher events coalesce to
619
+ // dirty). Re-write until the single in-flight build command is observed.
620
+ await writeUntilPredicate(() => child.buildSends().length === 2, path.join(root, 'pages', 'index', 'index.js'), attempt => `Page({ data: { msg: "will fail-${attempt}" } })\n`);
621
+ expect(child.buildSends().length).toBe(2);
622
+ child.emit('message', {
623
+ type: 'result',
624
+ appInfo: null,
625
+ error: { message: 'boom — esbuild exploded' },
626
+ });
627
+ await vi.waitFor(() => {
628
+ expect(buildErrors.length, 'an error reply must surface through onBuildError — treating it as appInfo:null makes the rebuild path '
629
+ + 'report 编译完成/hot-reload SUCCESS for a build the worker itself flagged as failed').toBeGreaterThanOrEqual(1);
630
+ }, { timeout: 5000 });
631
+ expect(buildErrors[0]).toBeInstanceOf(Error);
632
+ expect(String(buildErrors[0].message), 'the worker-reported error message must pass through to onBuildError (the devtools panel renders it)').toMatch(/esbuild exploded/);
633
+ expect(rebuilds, 'the failed rebuild must NOT fire onRebuild — onRebuild triggers the hot-reload toast').toHaveLength(0);
634
+ }, 45_000);
635
+ it("M2: a fork 'error' event with NO accompanying 'exit' settles the in-flight build (bounded) and the next build re-forks a fresh worker", async () => {
636
+ useSilentChildren();
637
+ const worker = devkit.createCompileWorker({});
638
+ const pending = worker.build(REQUEST);
639
+ await vi.waitFor(() => {
640
+ expect(children.length).toBe(1);
641
+ }, { timeout: 5000 });
642
+ const first = children[0];
643
+ // Node does NOT guarantee 'exit' after 'error' (spawn/IPC failures can
644
+ // surface as a lone 'error' event). Swallowing it leaves the in-flight
645
+ // build pending forever.
646
+ first.emit('error', new Error('spawn EAGAIN'));
647
+ expect(await raceSettle(pending, 2000), "a child 'error' event without 'exit' must settle (reject) the in-flight build — today it hangs the rebuild scheduler forever").toBe('rejected');
648
+ // The errored child must be discarded so the NEXT build re-forks.
649
+ const second = worker.build(REQUEST);
650
+ await vi.waitFor(() => {
651
+ expect(children.length, "after a fork 'error' the child must be cleared — the next build must fork a FRESH worker, not reuse the broken one").toBe(2);
652
+ }, { timeout: 5000 });
653
+ const fresh = children[1];
654
+ await vi.waitFor(() => {
655
+ expect(fresh.buildSends().length).toBe(1);
656
+ }, { timeout: 5000 });
657
+ fresh.respondToBuild('/tmp/p');
658
+ await expect(second).resolves.toEqual(expect.objectContaining({ appId: WORKER_APP.appId }));
659
+ }, 45_000);
660
+ it("M2: 'error' followed by a LATE 'exit' is idempotent — the stale exit must not settle the NEXT build on the fresh worker", async () => {
661
+ useSilentChildren();
662
+ const worker = devkit.createCompileWorker({});
663
+ const pending = worker.build(REQUEST);
664
+ await vi.waitFor(() => {
665
+ expect(children.length).toBe(1);
666
+ }, { timeout: 5000 });
667
+ const first = children[0];
668
+ first.emit('error', new Error('spawn failure'));
669
+ expect(await raceSettle(pending, 2000), "the 'error' event alone must settle the in-flight build (see the companion M2 test)").toBe('rejected');
670
+ // Start the recovery build, THEN let the dead child's 'exit' fire late
671
+ // (the real-world double-fire). The stale exit must not reject the new
672
+ // in-flight build that belongs to the fresh worker.
673
+ const second = worker.build(REQUEST);
674
+ await vi.waitFor(() => {
675
+ expect(children.length).toBe(2);
676
+ }, { timeout: 5000 });
677
+ first.crash(1);
678
+ await sleep(20);
679
+ const fresh = children[1];
680
+ await vi.waitFor(() => {
681
+ expect(fresh.buildSends().length).toBe(1);
682
+ }, { timeout: 5000 });
683
+ fresh.respondToBuild('/tmp/p');
684
+ await expect(second, "the dead child's late 'exit' must be a no-op for the new generation — error+exit double-fire settles ONE build, once").resolves.toEqual(expect.objectContaining({ appId: WORKER_APP.appId }));
685
+ }, 45_000);
686
+ it("M3: close() returns a promise that resolves only AFTER the child actually exited — kill-and-return is not a close", async () => {
687
+ const worker = devkit.createCompileWorker({});
688
+ await worker.build(REQUEST);
689
+ const child = children[0];
690
+ // From here the child only dies when the test says so.
691
+ child.manualExit = true;
692
+ const closeResult = worker.close();
693
+ expect(closeResult, 'close() must return a promise tied to the child exit — a void kill-and-return lets callers proceed while the compiler is still dying').toBeInstanceOf(Promise);
694
+ let settledEarly = false;
695
+ void closeResult.then(() => {
696
+ settledEarly = true;
697
+ }, () => {
698
+ settledEarly = true;
699
+ });
700
+ await sleep(50);
701
+ expect(settledEarly, "close()'s promise must NOT settle before the child emitted 'exit' — resolving early defeats the whole guarantee").toBe(false);
702
+ child.emit('exit', null, 'SIGTERM');
703
+ await closeResult;
704
+ }, 45_000);
705
+ it('M3: close() itself rejects the in-flight build — even when the child never emits exit', async () => {
706
+ useSilentChildren();
707
+ const worker = devkit.createCompileWorker({});
708
+ const pending = worker.build(REQUEST);
709
+ await vi.waitFor(() => {
710
+ expect(children.length).toBe(1);
711
+ }, { timeout: 5000 });
712
+ const child = children[0];
713
+ // A wedged child that ignores SIGTERM: kill() never produces 'exit'.
714
+ child.manualExit = true;
715
+ worker.close();
716
+ expect(await raceSettle(pending, 1500), 'close() must settle the in-flight build ITSELF — delegating the rejection to a child exit that may never '
717
+ + 'come leaves the rebuild scheduler hanging at teardown').toBe('rejected');
718
+ }, 45_000);
719
+ /**
720
+ * CODEX RE-REVIEW — M3 NOT-RESOLVED follow-up. `settleDeath`'s
721
+ * `removeAllListeners()` also strips the `once('exit', resolve)` that
722
+ * close() registered on the SAME child: when a child dies through the
723
+ * 'error' path while a close() is in flight (kill sent, exit not yet
724
+ * emitted), the closePromise loses its only resolver and hangs forever.
725
+ * Contract: a child 'error' during an in-flight close is a death signal
726
+ * (Node does NOT guarantee an 'exit' after 'error' — see M2) — the close
727
+ * promise must still resolve, whether a late 'exit' follows or never comes.
728
+ * The normal-exit-resolves case is already pinned above ("M3: close()
729
+ * returns a promise that resolves only AFTER the child actually exited").
730
+ */
731
+ it("M3 follow-up: a child 'error' during an in-flight close() must not strip the close resolver — closePromise resolves even though only a LATE 'exit' follows", async () => {
732
+ const worker = devkit.createCompileWorker({});
733
+ await worker.build(REQUEST);
734
+ const child = children[0];
735
+ // kill() no longer auto-exits: close() stays in flight until the test
736
+ // drives the death events by hand.
737
+ child.manualExit = true;
738
+ const closeResult = worker.close();
739
+ expect(child.kill).toHaveBeenCalled();
740
+ // The child dies via 'error' first (settleDeath runs and — today —
741
+ // removeAllListeners() takes close()'s once('exit') resolver with it)…
742
+ child.emit('error', new Error('EPIPE'));
743
+ // …then the real-world late 'exit' fires. With the resolver stripped,
744
+ // nobody is listening and closePromise hangs forever.
745
+ child.emit('exit', null, 'SIGTERM');
746
+ expect(await raceSettle(closeResult, 1500), "settleDeath's removeAllListeners must not strip close()'s exit resolver — a child that dies via "
747
+ + "'error' mid-close leaves closePromise hanging forever, wedging every awaiter of session.close()").toBe('resolved');
748
+ }, 45_000);
749
+ it("M3 follow-up: a child 'error' during an in-flight close() with NO 'exit' ever resolves the close too — 'error' is a death signal, not a wait-longer signal", async () => {
750
+ const worker = devkit.createCompileWorker({});
751
+ await worker.build(REQUEST);
752
+ const child = children[0];
753
+ child.manualExit = true;
754
+ const closeResult = worker.close();
755
+ expect(child.kill).toHaveBeenCalled();
756
+ // Node does NOT guarantee an 'exit' after 'error' (the exact premise M2
757
+ // pinned for builds): the lone 'error' must settle the close as well.
758
+ child.emit('error', new Error('spawn EAGAIN'));
759
+ expect(await raceSettle(closeResult, 1500), "a lone child 'error' during close() must resolve the close promise — settleDeath already treats "
760
+ + "'error' as death everywhere else (clears the child, rejects builds); close() waiting for an 'exit' "
761
+ + 'that Node never guarantees hangs teardown forever').toBe('resolved');
762
+ }, 45_000);
763
+ it('m7: a final line WITHOUT a trailing newline is flushed to onLog exactly once when the stream ends', async () => {
764
+ const root = makeFixture();
765
+ const entries = [];
766
+ const logOpts = { onLog: (entry) => entries.push(entry) };
767
+ const session = await devkit.openProject({
768
+ projectPath: root,
769
+ watch: false,
770
+ outputDir: path.join(root, '.out'),
771
+ ...logOpts,
772
+ });
773
+ openSessions.push(session);
774
+ const child = theChild();
775
+ entries.length = 0;
776
+ // dmcc's last line (often the error summary) can end without a '\n'
777
+ // when the process dies — the buffered remainder must still surface.
778
+ child.stderr.write('pages/index/index.js 编译出错: Transform failed');
779
+ child.stderr.end();
780
+ await vi.waitFor(() => {
781
+ expect(entries.filter(entry => entry.text.includes('编译出错')).length, 'the un-terminated final line must be flushed to onLog when the stream ends — today it is buffered forever and silently dropped').toBe(1);
782
+ }, { timeout: 3000 });
783
+ await sleep(50);
784
+ // PassThrough emits BOTH 'end' and 'close' — the flush must not double-fire.
785
+ expect(entries).toHaveLength(1);
786
+ expect(entries[0]).toEqual({
787
+ stream: 'stderr',
788
+ text: 'pages/index/index.js 编译出错: Transform failed',
789
+ });
790
+ }, 45_000);
791
+ });