@dimina-kit/devkit 0.1.2-dev.20260618090552 → 0.1.2-dev.20260624084417
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.
- package/dist/compile-worker-entry.test.js +10 -11
- package/dist/compile-worker-leak.test.js +16 -18
- package/dist/compile-worker.test.js +44 -51
- package/dist/index.js +1 -1
- package/dist/open-project-cleanup.test.js +12 -13
- package/dist/open-project-compile-log.test.js +2 -2
- package/dist/rebuild-scheduler.test.js +4 -4
- package/dist/watch-rebuild.testutil.d.ts.map +1 -1
- package/dist/watch-rebuild.testutil.js +3 -4
- package/package.json +6 -4
|
@@ -159,21 +159,20 @@ describe('compile-worker-entry — wireCompileWorkerProcess (orphan-safety-net s
|
|
|
159
159
|
});
|
|
160
160
|
});
|
|
161
161
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* module evaluation under a manipulated argv[1].
|
|
162
|
+
* The fork-mode self-wiring gate must NOT use a SUBSTRING match on
|
|
163
|
+
* `process.argv[1]`: any unrelated script whose path merely contains
|
|
164
|
+
* `compile-worker-entry` (run with an IPC channel, e.g. itself a fork) would
|
|
165
|
+
* import this module and silently self-wire — a disconnect→exit(0) handler and
|
|
166
|
+
* a message handler hijacking the host's IPC traffic. The gate must engage
|
|
167
|
+
* ONLY when argv[1] is THIS module (same path as import.meta.url) — pinned
|
|
168
|
+
* behaviorally below via a fresh module evaluation under a manipulated argv[1].
|
|
170
169
|
*
|
|
171
170
|
* Observation seam: self-wiring is `process.on('message'|'disconnect', …)` on
|
|
172
171
|
* the REAL process — the tests diff the process listener sets around the
|
|
173
172
|
* import and remove any added listeners again in cleanup (so a failing run
|
|
174
173
|
* can never leak a disconnect→exit(0) handler into the vitest worker).
|
|
175
174
|
*/
|
|
176
|
-
describe('compile-worker-entry — fork-mode self-wiring gate
|
|
175
|
+
describe('compile-worker-entry — fork-mode self-wiring gate', () => {
|
|
177
176
|
function snapshotListeners() {
|
|
178
177
|
return {
|
|
179
178
|
message: process.listeners('message'),
|
|
@@ -198,7 +197,7 @@ describe('compile-worker-entry — fork-mode self-wiring gate (codex m9)', () =>
|
|
|
198
197
|
vi.resetModules();
|
|
199
198
|
await import('./compile-worker-entry.js');
|
|
200
199
|
}
|
|
201
|
-
it('
|
|
200
|
+
it('an argv[1] LOOKALIKE path (substring match) must NOT self-wire the worker IPC handlers', async () => {
|
|
202
201
|
const originalArgv1 = process.argv[1] ?? '';
|
|
203
202
|
const originalSend = process.send;
|
|
204
203
|
const before = snapshotListeners();
|
|
@@ -222,7 +221,7 @@ describe('compile-worker-entry — fork-mode self-wiring gate (codex m9)', () =>
|
|
|
222
221
|
vi.resetModules();
|
|
223
222
|
}
|
|
224
223
|
});
|
|
225
|
-
it('
|
|
224
|
+
it('positive control: when argv[1] IS this entry module, the fork-mode self-wiring engages', async () => {
|
|
226
225
|
const originalArgv1 = process.argv[1] ?? '';
|
|
227
226
|
const originalSend = process.send;
|
|
228
227
|
const before = snapshotListeners();
|
|
@@ -15,13 +15,13 @@ import { writeUntilPredicate } from './watch-rebuild.testutil.js';
|
|
|
15
15
|
* > 0) re-writes the source file until the rebuild's first log line appears.
|
|
16
16
|
*/
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* REAL-PROCESS integration contract: a closed project must take its compile
|
|
19
|
+
* worker down with it. Nothing in this file mocks `child_process`: every test
|
|
20
|
+
* forks the actual `compile-worker-entry` (and, for the `openProject` tests,
|
|
21
|
+
* runs the actual `@dimina/compiler`) and asserts on REAL process death via
|
|
22
|
+
* `process.kill(pid, 0)` (ESRCH ⇒ the PID is gone).
|
|
23
23
|
*
|
|
24
|
-
* Why these tests exist on top of the
|
|
24
|
+
* Why these tests exist on top of the mocked-fork suite
|
|
25
25
|
* (`compile-worker.test.ts` pins `child.kill` was CALLED): a kill() spy
|
|
26
26
|
* proves intent, not death. The two leak classes this file closes:
|
|
27
27
|
*
|
|
@@ -38,12 +38,11 @@ import { writeUntilPredicate } from './watch-rebuild.testutil.js';
|
|
|
38
38
|
* actually dead (not merely "kill was invoked"), including when a build
|
|
39
39
|
* is in flight at close time.
|
|
40
40
|
*
|
|
41
|
-
* PID discovery
|
|
42
|
-
* is
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* `worker.pid`, nothing here constrains it either way.
|
|
41
|
+
* PID discovery: NO public API is pinned for exposing the worker PID. The
|
|
42
|
+
* worker is discovered externally via a `ps -axo pid=,ppid=,command=` scan for
|
|
43
|
+
* children of THIS test process whose command line contains
|
|
44
|
+
* `compile-worker-entry`. That keeps the contract purely behavioral — exposing
|
|
45
|
+
* `worker.pid` later is unconstrained either way.
|
|
47
46
|
*
|
|
48
47
|
* CI stability: all death checks are bounded polls (8s deadline, 100ms
|
|
49
48
|
* interval), and every discovered/forked PID is SIGKILLed in cleanup so a
|
|
@@ -256,13 +255,12 @@ describe('② session.close() — the worker PID is ACTUALLY dead afterwards (re
|
|
|
256
255
|
+ 'a busy worker that survives close is the classic "compile still running after project closed" leak').toBe(true);
|
|
257
256
|
}, 90_000);
|
|
258
257
|
/**
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
* ever needs a grace poll.
|
|
258
|
+
* The tests above tolerate an 8s post-close death poll. The actual contract
|
|
259
|
+
* is stronger — `await session.close()` must RETURN only after the worker
|
|
260
|
+
* already exited (close() awaits the child 'exit'), so the await itself is
|
|
261
|
+
* the death guarantee and no caller ever needs a grace poll.
|
|
264
262
|
*/
|
|
265
|
-
it("
|
|
263
|
+
it("await session.close() returns only AFTER the worker PID is already dead — the await IS the guarantee, no grace poll", async () => {
|
|
266
264
|
const root = makeFixture();
|
|
267
265
|
const session = await devkit.openProject({
|
|
268
266
|
projectPath: root,
|
|
@@ -11,8 +11,7 @@ import { writeUntilPredicate, writeUntilSettled } from './watch-rebuild.testutil
|
|
|
11
11
|
* below depend on a REAL chokidar inotify watch (fork is mocked, chokidar is
|
|
12
12
|
* not). Under CI load (concurrent real dmcc compile in
|
|
13
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).
|
|
14
|
+
* inotify event can be dropped, hanging `await rebuilt`/`vi.waitFor` forever.
|
|
16
15
|
* Count-agnostic / `>=` waiters are wrapped in writeUntilSettled /
|
|
17
16
|
* writeUntilPredicate, which RE-WRITE the source file (micro-varied content →
|
|
18
17
|
* fresh inotify event) until the rebuild lands; the rebuild scheduler
|
|
@@ -21,16 +20,12 @@ import { writeUntilPredicate, writeUntilSettled } from './watch-rebuild.testutil
|
|
|
21
20
|
* them (see its inline note).
|
|
22
21
|
*/
|
|
23
22
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* (NOT yet implemented).
|
|
23
|
+
* Contract for the PARENT side: `openProject` orchestrating a long-lived
|
|
24
|
+
* forked compile worker.
|
|
27
25
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.
|
|
26
|
+
* Compilation runs in a forked long-lived child process (which chdirs in its
|
|
27
|
+
* own process) rather than the in-process `require('@dimina/compiler')` call
|
|
28
|
+
* with its host-global `process.chdir`.
|
|
34
29
|
*
|
|
35
30
|
* Parent-side contract pinned here (child_process.fork is mocked; the fake
|
|
36
31
|
* child is an EventEmitter with PassThrough stdout/stderr + send/kill spies):
|
|
@@ -476,10 +471,10 @@ describe('openProject — fork-based compile worker orchestration', () => {
|
|
|
476
471
|
}, 45_000);
|
|
477
472
|
});
|
|
478
473
|
/**
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
474
|
+
* Close-time guards on the exported `createCompileWorker` directly (same
|
|
475
|
+
* mocked fork as above). Real process death is pinned by
|
|
476
|
+
* `compile-worker-leak.test.ts`; these pin the parent-side STATE MACHINE
|
|
477
|
+
* around close:
|
|
483
478
|
* - close with a build in flight kills immediately and SETTLES the pending
|
|
484
479
|
* promise (a hung promise wedges the rebuild scheduler — and a scheduler
|
|
485
480
|
* wedged at close time is itself a teardown leak),
|
|
@@ -546,23 +541,22 @@ describe('createCompileWorker — close-time leak guards (direct use)', () => {
|
|
|
546
541
|
}, 45_000);
|
|
547
542
|
});
|
|
548
543
|
/**
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
* mocked-fork harness as above; each test names the finding it pins.
|
|
544
|
+
* Regression guards for worker error replies, fork errors, close semantics,
|
|
545
|
+
* and the trailing line. Same mocked-fork harness as above.
|
|
552
546
|
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
547
|
+
* - a `{ type:'result', error }` reply must NOT resolve as a SUCCESS with
|
|
548
|
+
* appInfo:null — it must reject the in-flight build (message passed
|
|
549
|
+
* through) and surface via onBuildError on the rebuild path.
|
|
550
|
+
* - a child 'error' event (spawn/IPC failure) must not be swallowed; Node
|
|
551
|
+
* does NOT guarantee an accompanying 'exit', so the in-flight build would
|
|
552
|
+
* hang forever and the dead child would never be replaced.
|
|
553
|
+
* - close() must return a promise that resolves only after the child actually
|
|
554
|
+
* exited, and must settle the in-flight build itself (not rely on the child
|
|
555
|
+
* cooperating with exit).
|
|
556
|
+
* - a final line without a trailing newline must be flushed to onLog exactly
|
|
557
|
+
* once when the stream ends (not buffered forever).
|
|
564
558
|
*/
|
|
565
|
-
describe('
|
|
559
|
+
describe('worker error replies, fork errors, close semantics, trailing line', () => {
|
|
566
560
|
const REQUEST = {
|
|
567
561
|
projectPath: '/tmp/p',
|
|
568
562
|
outputDir: '/tmp/out',
|
|
@@ -583,7 +577,7 @@ describe('codex review regressions — worker error replies, fork errors, close
|
|
|
583
577
|
return child;
|
|
584
578
|
});
|
|
585
579
|
}
|
|
586
|
-
it('
|
|
580
|
+
it('build() rejects (error message passed through) when the worker reply carries an error — not a silent appInfo:null success', async () => {
|
|
587
581
|
useSilentChildren();
|
|
588
582
|
const worker = devkit.createCompileWorker({});
|
|
589
583
|
const pending = worker.build(REQUEST);
|
|
@@ -600,7 +594,7 @@ describe('codex review regressions — worker error replies, fork errors, close
|
|
|
600
594
|
});
|
|
601
595
|
await settled;
|
|
602
596
|
}, 45_000);
|
|
603
|
-
it('
|
|
597
|
+
it('a rebuild whose worker reply carries an error settles through onBuildError — NOT through onRebuild as a fake hot-reload success', async () => {
|
|
604
598
|
const root = makeFixture();
|
|
605
599
|
const buildErrors = [];
|
|
606
600
|
const rebuilds = [];
|
|
@@ -632,7 +626,7 @@ describe('codex review regressions — worker error replies, fork errors, close
|
|
|
632
626
|
expect(String(buildErrors[0].message), 'the worker-reported error message must pass through to onBuildError (the devtools panel renders it)').toMatch(/esbuild exploded/);
|
|
633
627
|
expect(rebuilds, 'the failed rebuild must NOT fire onRebuild — onRebuild triggers the hot-reload toast').toHaveLength(0);
|
|
634
628
|
}, 45_000);
|
|
635
|
-
it("
|
|
629
|
+
it("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
630
|
useSilentChildren();
|
|
637
631
|
const worker = devkit.createCompileWorker({});
|
|
638
632
|
const pending = worker.build(REQUEST);
|
|
@@ -657,7 +651,7 @@ describe('codex review regressions — worker error replies, fork errors, close
|
|
|
657
651
|
fresh.respondToBuild('/tmp/p');
|
|
658
652
|
await expect(second).resolves.toEqual(expect.objectContaining({ appId: WORKER_APP.appId }));
|
|
659
653
|
}, 45_000);
|
|
660
|
-
it("
|
|
654
|
+
it("'error' followed by a LATE 'exit' is idempotent — the stale exit must not settle the NEXT build on the fresh worker", async () => {
|
|
661
655
|
useSilentChildren();
|
|
662
656
|
const worker = devkit.createCompileWorker({});
|
|
663
657
|
const pending = worker.build(REQUEST);
|
|
@@ -666,7 +660,7 @@ describe('codex review regressions — worker error replies, fork errors, close
|
|
|
666
660
|
}, { timeout: 5000 });
|
|
667
661
|
const first = children[0];
|
|
668
662
|
first.emit('error', new Error('spawn failure'));
|
|
669
|
-
expect(await raceSettle(pending, 2000), "the 'error' event alone must settle the in-flight build
|
|
663
|
+
expect(await raceSettle(pending, 2000), "the 'error' event alone must settle the in-flight build").toBe('rejected');
|
|
670
664
|
// Start the recovery build, THEN let the dead child's 'exit' fire late
|
|
671
665
|
// (the real-world double-fire). The stale exit must not reject the new
|
|
672
666
|
// in-flight build that belongs to the fresh worker.
|
|
@@ -683,7 +677,7 @@ describe('codex review regressions — worker error replies, fork errors, close
|
|
|
683
677
|
fresh.respondToBuild('/tmp/p');
|
|
684
678
|
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
679
|
}, 45_000);
|
|
686
|
-
it("
|
|
680
|
+
it("close() returns a promise that resolves only AFTER the child actually exited — kill-and-return is not a close", async () => {
|
|
687
681
|
const worker = devkit.createCompileWorker({});
|
|
688
682
|
await worker.build(REQUEST);
|
|
689
683
|
const child = children[0];
|
|
@@ -702,7 +696,7 @@ describe('codex review regressions — worker error replies, fork errors, close
|
|
|
702
696
|
child.emit('exit', null, 'SIGTERM');
|
|
703
697
|
await closeResult;
|
|
704
698
|
}, 45_000);
|
|
705
|
-
it('
|
|
699
|
+
it('close() itself rejects the in-flight build — even when the child never emits exit', async () => {
|
|
706
700
|
useSilentChildren();
|
|
707
701
|
const worker = devkit.createCompileWorker({});
|
|
708
702
|
const pending = worker.build(REQUEST);
|
|
@@ -717,18 +711,17 @@ describe('codex review regressions — worker error replies, fork errors, close
|
|
|
717
711
|
+ 'come leaves the rebuild scheduler hanging at teardown').toBe('rejected');
|
|
718
712
|
}, 45_000);
|
|
719
713
|
/**
|
|
720
|
-
*
|
|
721
|
-
* `
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
* (Node does NOT guarantee an 'exit' after 'error' — see M2) — the close
|
|
714
|
+
* `settleDeath`'s `removeAllListeners()` must not strip the
|
|
715
|
+
* `once('exit', resolve)` that close() registered on the SAME child: when a
|
|
716
|
+
* child dies through the 'error' path while a close() is in flight (kill
|
|
717
|
+
* sent, exit not yet emitted), the closePromise must not lose its only
|
|
718
|
+
* resolver and hang forever. A child 'error' during an in-flight close is a
|
|
719
|
+
* death signal (Node does NOT guarantee an 'exit' after 'error') — the close
|
|
727
720
|
* promise must still resolve, whether a late 'exit' follows or never comes.
|
|
728
|
-
* The normal-exit-resolves case is
|
|
729
|
-
*
|
|
721
|
+
* The normal-exit-resolves case is pinned above ("close() returns a promise
|
|
722
|
+
* that resolves only AFTER the child actually exited").
|
|
730
723
|
*/
|
|
731
|
-
it("
|
|
724
|
+
it("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
725
|
const worker = devkit.createCompileWorker({});
|
|
733
726
|
await worker.build(REQUEST);
|
|
734
727
|
const child = children[0];
|
|
@@ -746,21 +739,21 @@ describe('codex review regressions — worker error replies, fork errors, close
|
|
|
746
739
|
expect(await raceSettle(closeResult, 1500), "settleDeath's removeAllListeners must not strip close()'s exit resolver — a child that dies via "
|
|
747
740
|
+ "'error' mid-close leaves closePromise hanging forever, wedging every awaiter of session.close()").toBe('resolved');
|
|
748
741
|
}, 45_000);
|
|
749
|
-
it("
|
|
742
|
+
it("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
743
|
const worker = devkit.createCompileWorker({});
|
|
751
744
|
await worker.build(REQUEST);
|
|
752
745
|
const child = children[0];
|
|
753
746
|
child.manualExit = true;
|
|
754
747
|
const closeResult = worker.close();
|
|
755
748
|
expect(child.kill).toHaveBeenCalled();
|
|
756
|
-
// Node does NOT guarantee an 'exit' after 'error'
|
|
757
|
-
//
|
|
749
|
+
// Node does NOT guarantee an 'exit' after 'error': the lone 'error' must
|
|
750
|
+
// settle the close as well.
|
|
758
751
|
child.emit('error', new Error('spawn EAGAIN'));
|
|
759
752
|
expect(await raceSettle(closeResult, 1500), "a lone child 'error' during close() must resolve the close promise — settleDeath already treats "
|
|
760
753
|
+ "'error' as death everywhere else (clears the child, rejects builds); close() waiting for an 'exit' "
|
|
761
754
|
+ 'that Node never guarantees hangs teardown forever').toBe('resolved');
|
|
762
755
|
}, 45_000);
|
|
763
|
-
it('
|
|
756
|
+
it('a final line WITHOUT a trailing newline is flushed to onLog exactly once when the stream ends', async () => {
|
|
764
757
|
const root = makeFixture();
|
|
765
758
|
const entries = [];
|
|
766
759
|
const logOpts = { onLog: (entry) => entries.push(entry) };
|
package/dist/index.js
CHANGED
|
@@ -68,7 +68,7 @@ export async function openProject(opts) {
|
|
|
68
68
|
// When the compiler is racing (e.g. opening a project that was just
|
|
69
69
|
// closed elsewhere in the same Electron process) `build()` can return
|
|
70
70
|
// null even though the manifest on disk is perfectly readable. The
|
|
71
|
-
//
|
|
71
|
+
// fallback to `{ appId: 'unknown' }` is load-bearing for
|
|
72
72
|
// any consumer that calls `wx.setStorageSync` — the dimina runtime
|
|
73
73
|
// stores values under `${appId}_${key}` and the devtools storage panel
|
|
74
74
|
// also derives its IPC prefix from `appInfo.appId`. A stale `unknown`
|
|
@@ -6,17 +6,16 @@ import { PassThrough } from 'node:stream';
|
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
7
|
import * as devkit from './index.js';
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* when `openProject` fails AFTER the compile worker was already forked.
|
|
9
|
+
* Resource cleanup when `openProject` fails AFTER the compile worker was
|
|
10
|
+
* already forked.
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
12
|
+
* - the worker is created before the dev server starts; a server-start
|
|
13
|
+
* failure that propagates out of openProject must still kill the worker
|
|
14
|
+
* (otherwise a leaked compiler process per failed open).
|
|
15
|
+
* - `createProjectWatcher().ready` only resolves on chokidar's 'ready'; a
|
|
16
|
+
* watcher 'error' (EMFILE, permission loss, …) before ready must REJECT
|
|
17
|
+
* `ready` — otherwise `await watcher?.ready` (and openProject) hangs
|
|
18
|
+
* forever, leaking the worker AND the already-listening dev server.
|
|
20
19
|
*
|
|
21
20
|
* Harness: same fake-fork pattern as compile-worker.test.ts, plus this file
|
|
22
21
|
* mocks the fe dev-server module and chokidar so the failure point of each
|
|
@@ -125,8 +124,8 @@ function makeFixture() {
|
|
|
125
124
|
function sleep(ms) {
|
|
126
125
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
127
126
|
}
|
|
128
|
-
describe('openProject failure cleanup — no leaked compile worker
|
|
129
|
-
it('
|
|
127
|
+
describe('openProject failure cleanup — no leaked compile worker', () => {
|
|
128
|
+
it('a dev-server start failure AFTER the worker was forked rejects openProject AND kills the worker', async () => {
|
|
130
129
|
const root = makeFixture();
|
|
131
130
|
mocks.feStart.mockReset();
|
|
132
131
|
mocks.feStart.mockRejectedValue(new Error('listen EADDRINUSE: address already in use'));
|
|
@@ -139,7 +138,7 @@ describe('openProject failure cleanup — no leaked compile worker (codex M4/M6)
|
|
|
139
138
|
expect(children[0].kill, 'a server-start failure must kill the already-forked compile worker — today the error propagates but the '
|
|
140
139
|
+ 'worker (a whole compiler process) is leaked on every failed open').toHaveBeenCalled();
|
|
141
140
|
}, 15_000);
|
|
142
|
-
it("
|
|
141
|
+
it("a watcher 'error' before 'ready' rejects openProject within a bounded time and kills the worker (no eternal hang)", async () => {
|
|
143
142
|
const root = makeFixture();
|
|
144
143
|
mocks.watch.mockReset();
|
|
145
144
|
mocks.watch.mockImplementation(() => {
|
|
@@ -65,7 +65,7 @@ describe('openProject onLog — first compile (integration, real dmcc)', () => {
|
|
|
65
65
|
...logOpts,
|
|
66
66
|
});
|
|
67
67
|
openSessions.push(session);
|
|
68
|
-
expect(entries.length, 'openProject must wire opts.onLog
|
|
68
|
+
expect(entries.length, 'openProject must wire opts.onLog around the first build() — no lines were captured').toBeGreaterThan(0);
|
|
69
69
|
const texts = entries.map(entry => entry.text);
|
|
70
70
|
expect(texts.some(text => /esbuild 转换失败/.test(text)), 'the `[logic] esbuild 转换失败 <file>` line is the highest-value error line and must be kept').toBe(true);
|
|
71
71
|
expect(texts.some(text => /^✖ /.test(text)), '`✖ …[FAILED: …]` listr failure lines must be kept').toBe(true);
|
|
@@ -124,7 +124,7 @@ describe('openProject onLog — watcher rebuild (integration, real dmcc)', () =>
|
|
|
124
124
|
// Count-agnostic (entries.length > 0): re-write until the rebuild lands so
|
|
125
125
|
// a dropped inotify event under CI load can't hang `await rebuilt`.
|
|
126
126
|
await writeUntilSettled(rebuilt, path.join(root, 'pages', 'index', 'index.js'), attempt => `Page({ data: { msg: "updated-${attempt}" } })\n`);
|
|
127
|
-
expect(entries.length, 'the rebuild() build() call must ALSO
|
|
127
|
+
expect(entries.length, 'the rebuild() build() call must ALSO deliver to onLog — no rebuild lines reached onLog').toBeGreaterThan(0);
|
|
128
128
|
const texts = entries.map(entry => entry.text);
|
|
129
129
|
expect(texts.some(text => /^✔ /.test(text)), 'rebuild stage-completion lines must be kept').toBe(true);
|
|
130
130
|
expectNoNoise(texts);
|
|
@@ -120,10 +120,10 @@ describe('createRebuildScheduler — saves during an in-flight build must not be
|
|
|
120
120
|
expect(run).toHaveBeenCalledTimes(2);
|
|
121
121
|
});
|
|
122
122
|
});
|
|
123
|
-
// ───
|
|
123
|
+
// ─── `run` throwing SYNCHRONOUSLY (not rejecting) ──────────────────
|
|
124
124
|
//
|
|
125
|
-
// Regression tests for
|
|
126
|
-
// `
|
|
125
|
+
// Regression tests for a wedge: `start()` sets `running = true` and then calls
|
|
126
|
+
// `run()` bare. If `run` throws synchronously
|
|
127
127
|
// (e.g. a config/IO error raised before the build's first await — no promise
|
|
128
128
|
// ever exists), the `.catch().then()` settle chain is never attached, so:
|
|
129
129
|
// - the exception escapes through `schedule()` to the watcher callsite,
|
|
@@ -135,7 +135,7 @@ describe('createRebuildScheduler — saves during an in-flight build must not be
|
|
|
135
135
|
// drops a pending dirty flag", with failures being `run`'s responsibility to
|
|
136
136
|
// report) must hold regardless of HOW the run fails: rejection and synchronous
|
|
137
137
|
// throw are the same event to the scheduler.
|
|
138
|
-
describe('
|
|
138
|
+
describe('createRebuildScheduler: run() throwing synchronously must not wedge the scheduler', () => {
|
|
139
139
|
/** A `run` that throws synchronously on the i-th call(s), resolves otherwise. */
|
|
140
140
|
function makeSyncThrowingRun(throwOnCalls) {
|
|
141
141
|
const run = vi.fn(() => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"watch-rebuild.testutil.d.ts","sourceRoot":"","sources":["../src/watch-rebuild.testutil.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"watch-rebuild.testutil.d.ts","sourceRoot":"","sources":["../src/watch-rebuild.testutil.ts"],"names":[],"mappings":"AA8BA;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CACtC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,EACzB,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GACrC,OAAO,CAAC,IAAI,CAAC,CAoBf;AAED;;;;;;;;GAQG;AACH,wBAAsB,mBAAmB,CACxC,SAAS,EAAE,MAAM,OAAO,EACxB,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GACrC,OAAO,CAAC,IAAI,CAAC,CAYf"}
|
|
@@ -10,8 +10,7 @@ import fs from 'node:fs';
|
|
|
10
10
|
* watch. A single `fs.writeFileSync` produces a single inotify `change` event,
|
|
11
11
|
* and under CI load (Linux inotify + a concurrent REAL dmcc compile in
|
|
12
12
|
* open-project-compile-log.test.ts pegging the CPU) chokidar can DROP that lone
|
|
13
|
-
* event — the rebuild never fires and the test waits forever
|
|
14
|
-
* timeout at compile-worker.test.ts:297).
|
|
13
|
+
* event — the rebuild never fires and the test waits forever until timeout.
|
|
15
14
|
*
|
|
16
15
|
* The fix is NOT a longer timeout (the event is lost, not slow): re-issue the
|
|
17
16
|
* filesystem write — with micro-varied content so size+mtime change and a fresh
|
|
@@ -20,8 +19,8 @@ import fs from 'node:fs';
|
|
|
20
19
|
* write into exactly ONE trailing build, so re-writing is safe for the
|
|
21
20
|
* count-agnostic / `>=` assertions these helpers are applied to.
|
|
22
21
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* This is flake hardening only: no assertion is touched. Only apply these
|
|
23
|
+
* helpers where the assertion is count-agnostic or `>=`
|
|
25
24
|
* (re-triggering an extra coalesced rebuild cannot change the outcome). Tests
|
|
26
25
|
* that pin an EXACT rebuild/build-send count must not use them.
|
|
27
26
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dimina-kit/devkit",
|
|
3
|
-
"version": "0.1.2-dev.
|
|
3
|
+
"version": "0.1.2-dev.20260624084417",
|
|
4
4
|
"description": "Development toolkit for Dimina mini-apps with H5 container preview",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dimina",
|
|
@@ -49,11 +49,12 @@
|
|
|
49
49
|
],
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/node": "^22.15.3",
|
|
52
|
+
"@vitest/coverage-v8": "^4.1.4",
|
|
52
53
|
"eslint": "^10.2.1",
|
|
53
54
|
"typescript": "5.9.2",
|
|
54
55
|
"vitest": "^4.1.4",
|
|
55
|
-
"@dimina-kit/
|
|
56
|
-
"@dimina-kit/
|
|
56
|
+
"@dimina-kit/typescript-config": "0.1.0",
|
|
57
|
+
"@dimina-kit/eslint-config": "0.1.0"
|
|
57
58
|
},
|
|
58
59
|
"dependencies": {
|
|
59
60
|
"@dimina/compiler": "1.0.16",
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
"check-types": "tsc --noEmit",
|
|
72
73
|
"lint": "eslint . --max-warnings 0",
|
|
73
74
|
"test": "vitest run",
|
|
74
|
-
"test:dev": "vitest"
|
|
75
|
+
"test:dev": "vitest",
|
|
76
|
+
"test:coverage": "vitest run --coverage"
|
|
75
77
|
}
|
|
76
78
|
}
|