@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,58 @@
1
+ /**
2
+ * Serialize rebuild runs with a dirty-flag + trailing rerun, replacing the old
3
+ * `if (isBuilding) return` early-exit in `openProject`'s rebuild loop, which
4
+ * silently DROPPED any watcher event that landed while a build was in flight
5
+ * (a save made during the ~1-2s compile window never produced a rebuild — the
6
+ * simulator stayed stale until the user saved again).
7
+ *
8
+ * Semantics:
9
+ * - `schedule()` while idle starts `run` immediately.
10
+ * - `schedule()` while `run` is in flight never starts a concurrent run
11
+ * (`build()` chdir()s into the project — concurrency would corrupt cwd);
12
+ * it marks the state dirty instead.
13
+ * - When the in-flight run settles and the state is dirty, exactly one
14
+ * trailing run starts: N saves during one build coalesce into 1 rerun.
15
+ * The trailing run is itself schedulable-against, recursively.
16
+ * - A rejecting run neither wedges the scheduler nor drops a pending dirty
17
+ * flag — `run` is expected to do its own error reporting (`onBuildError`).
18
+ */
19
+ export function createRebuildScheduler(run) {
20
+ let running = false;
21
+ let dirty = false;
22
+ function start() {
23
+ running = true;
24
+ // `run` stays synchronously invoked (idle schedule() starts the build
25
+ // in the same tick), but a SYNCHRONOUS throw must funnel into the same
26
+ // swallow-and-continue path as a rejection — a bare `run()` call would
27
+ // let the throw escape before `.catch` attaches, leaving `running`
28
+ // stuck at true and wedging every future schedule().
29
+ let settled;
30
+ try {
31
+ settled = run();
32
+ }
33
+ catch {
34
+ settled = Promise.reject();
35
+ }
36
+ settled
37
+ .catch(() => {
38
+ // Failures are `run`'s responsibility to report; the scheduler
39
+ // only guarantees liveness (no wedge, no lost dirty flag).
40
+ })
41
+ .then(() => {
42
+ running = false;
43
+ if (dirty) {
44
+ dirty = false;
45
+ start();
46
+ }
47
+ });
48
+ }
49
+ return {
50
+ schedule() {
51
+ if (running) {
52
+ dirty = true;
53
+ return;
54
+ }
55
+ start();
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=rebuild-scheduler.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rebuild-scheduler.test.d.ts","sourceRoot":"","sources":["../src/rebuild-scheduler.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,201 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import * as devkit from './index.js';
3
+ function getCreateRebuildScheduler() {
4
+ const factory = devkit.createRebuildScheduler;
5
+ expect(typeof factory, 'devkit must export createRebuildScheduler(run) — the dirty-flag + trailing-rerun replacement for the "if (isBuilding) return" drop in openProject\'s rebuild()').toBe('function');
6
+ return factory;
7
+ }
8
+ /** A `run` whose completion the test controls call-by-call. */
9
+ function makeDeferredRun() {
10
+ const pending = [];
11
+ const run = vi.fn(() => new Promise((resolve, reject) => {
12
+ pending.push({ resolve, reject });
13
+ }));
14
+ return {
15
+ run,
16
+ /** Settle the i-th started run (0-based). */
17
+ finish: (i) => pending[i].resolve(),
18
+ fail: (i, err) => pending[i].reject(err),
19
+ };
20
+ }
21
+ /** Let queued microtasks/then-chains drain. */
22
+ async function settle(ms = 25) {
23
+ await new Promise(resolve => setTimeout(resolve, ms));
24
+ }
25
+ afterEach(() => {
26
+ vi.restoreAllMocks();
27
+ });
28
+ describe('createRebuildScheduler — saves during an in-flight build must not be dropped', () => {
29
+ it('runs the build immediately when idle', async () => {
30
+ const createRebuildScheduler = getCreateRebuildScheduler();
31
+ const { run } = makeDeferredRun();
32
+ const scheduler = createRebuildScheduler(run);
33
+ scheduler.schedule();
34
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1));
35
+ });
36
+ it('never starts a concurrent build while one is in flight', async () => {
37
+ const createRebuildScheduler = getCreateRebuildScheduler();
38
+ const { run } = makeDeferredRun();
39
+ const scheduler = createRebuildScheduler(run);
40
+ scheduler.schedule();
41
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1));
42
+ // Saves landing mid-build must not spawn parallel compiler runs
43
+ // (build() chdir()s into the project — concurrency would corrupt cwd).
44
+ scheduler.schedule();
45
+ scheduler.schedule();
46
+ await settle();
47
+ expect(run).toHaveBeenCalledTimes(1);
48
+ });
49
+ it('coalesces N saves during one build into EXACTLY ONE trailing rebuild', async () => {
50
+ const createRebuildScheduler = getCreateRebuildScheduler();
51
+ const { run, finish } = makeDeferredRun();
52
+ const scheduler = createRebuildScheduler(run);
53
+ scheduler.schedule();
54
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1));
55
+ // Three rapid saves while build #0 is still compiling.
56
+ scheduler.schedule();
57
+ scheduler.schedule();
58
+ scheduler.schedule();
59
+ await settle();
60
+ expect(run, 'no concurrent run may start').toHaveBeenCalledTimes(1);
61
+ // Build #0 finishes → the dirty flag triggers ONE trailing rerun.
62
+ finish(0);
63
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2));
64
+ // The trailing run finishes with nothing else dirty → quiescent.
65
+ finish(1);
66
+ await settle();
67
+ expect(run, '3 saves during one build = 1 trailing rebuild, not 3').toHaveBeenCalledTimes(2);
68
+ });
69
+ it('does NOT rerun when nothing was scheduled during the build', async () => {
70
+ const createRebuildScheduler = getCreateRebuildScheduler();
71
+ const { run, finish } = makeDeferredRun();
72
+ const scheduler = createRebuildScheduler(run);
73
+ scheduler.schedule();
74
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1));
75
+ finish(0);
76
+ await settle();
77
+ expect(run, 'a clean build with no mid-flight saves must not loop').toHaveBeenCalledTimes(1);
78
+ });
79
+ it('saves during the trailing run coalesce again (chained trailing runs)', async () => {
80
+ const createRebuildScheduler = getCreateRebuildScheduler();
81
+ const { run, finish } = makeDeferredRun();
82
+ const scheduler = createRebuildScheduler(run);
83
+ scheduler.schedule();
84
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1));
85
+ scheduler.schedule(); // dirty during run #0
86
+ finish(0);
87
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2));
88
+ scheduler.schedule(); // dirty during trailing run #1
89
+ scheduler.schedule();
90
+ finish(1);
91
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(3));
92
+ finish(2);
93
+ await settle();
94
+ expect(run).toHaveBeenCalledTimes(3);
95
+ });
96
+ it('stays usable after the build rejects', async () => {
97
+ const createRebuildScheduler = getCreateRebuildScheduler();
98
+ const { run, fail } = makeDeferredRun();
99
+ const scheduler = createRebuildScheduler(run);
100
+ scheduler.schedule();
101
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1));
102
+ fail(0, new Error('compile exploded'));
103
+ await settle();
104
+ // A later save must still trigger a build — the scheduler must not wedge.
105
+ scheduler.schedule();
106
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2));
107
+ });
108
+ it('a save during a FAILING build still gets its trailing rebuild', async () => {
109
+ const createRebuildScheduler = getCreateRebuildScheduler();
110
+ const { run, finish, fail } = makeDeferredRun();
111
+ const scheduler = createRebuildScheduler(run);
112
+ scheduler.schedule();
113
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1));
114
+ scheduler.schedule(); // the save that fixes the broken code
115
+ fail(0, new Error('syntax error mid-edit'));
116
+ // The dirty flag set during the failing build must survive the failure.
117
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2));
118
+ finish(1);
119
+ await settle();
120
+ expect(run).toHaveBeenCalledTimes(2);
121
+ });
122
+ });
123
+ // ─── ROUND 2 — `run` throwing SYNCHRONOUSLY (not rejecting) ──────────────────
124
+ //
125
+ // Regression tests for the wedge found in review: `start()` sets
126
+ // `running = true` and then calls `run()` bare. If `run` throws synchronously
127
+ // (e.g. a config/IO error raised before the build's first await — no promise
128
+ // ever exists), the `.catch().then()` settle chain is never attached, so:
129
+ // - the exception escapes through `schedule()` to the watcher callsite,
130
+ // - `running` stays true FOREVER,
131
+ // - every later `schedule()` only sets `dirty` and is silently swallowed —
132
+ // the project never rebuilds again until the process restarts.
133
+ //
134
+ // The documented contract ("a failing run neither wedges the scheduler nor
135
+ // drops a pending dirty flag", with failures being `run`'s responsibility to
136
+ // report) must hold regardless of HOW the run fails: rejection and synchronous
137
+ // throw are the same event to the scheduler.
138
+ describe('ROUND 2 — createRebuildScheduler: run() throwing synchronously must not wedge the scheduler', () => {
139
+ /** A `run` that throws synchronously on the i-th call(s), resolves otherwise. */
140
+ function makeSyncThrowingRun(throwOnCalls) {
141
+ const run = vi.fn(() => {
142
+ if (throwOnCalls.includes(run.mock.calls.length)) {
143
+ throw new Error('sync failure before any promise exists');
144
+ }
145
+ return Promise.resolve();
146
+ });
147
+ return run;
148
+ }
149
+ it('schedule() does not propagate a synchronous throw from run (same swallow semantics as a rejection)', () => {
150
+ const createRebuildScheduler = getCreateRebuildScheduler();
151
+ const run = makeSyncThrowingRun([1]);
152
+ const scheduler = createRebuildScheduler(run);
153
+ // BUG CAUGHT: today the throw escapes start() → schedule() → the watcher
154
+ // callsite, which never expected schedule() to throw.
155
+ expect(() => scheduler.schedule()).not.toThrow();
156
+ expect(run).toHaveBeenCalledTimes(1);
157
+ });
158
+ it('a later save still triggers a build after run threw synchronously (no permanent running=true wedge)', async () => {
159
+ const createRebuildScheduler = getCreateRebuildScheduler();
160
+ const run = makeSyncThrowingRun([1]);
161
+ const scheduler = createRebuildScheduler(run);
162
+ // Tolerate the current buggy escape so this test isolates the WEDGE, not
163
+ // the throw itself (the throw is pinned by the test above).
164
+ try {
165
+ scheduler.schedule();
166
+ }
167
+ catch {
168
+ // current buggy behavior — swallowed so we can probe liveness
169
+ }
170
+ expect(run).toHaveBeenCalledTimes(1);
171
+ await settle();
172
+ // BUG CAUGHT: `running` was never reset, so this schedule() only sets
173
+ // `dirty` and the rebuild is silently dropped forever.
174
+ scheduler.schedule();
175
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2));
176
+ });
177
+ it('a dirty flag set during a synchronously-throwing run still gets its trailing rebuild', async () => {
178
+ const createRebuildScheduler = getCreateRebuildScheduler();
179
+ // Run #1: a watcher event lands re-entrantly while the build is starting
180
+ // (running=true → marks dirty), THEN the build throws synchronously.
181
+ const run = vi.fn(() => {
182
+ if (run.mock.calls.length === 1) {
183
+ scheduler.schedule(); // the save that fixes the broken state
184
+ throw new Error('sync failure before any promise exists');
185
+ }
186
+ return Promise.resolve();
187
+ });
188
+ const scheduler = createRebuildScheduler(run);
189
+ try {
190
+ scheduler.schedule();
191
+ }
192
+ catch {
193
+ // current buggy behavior — swallowed so we can probe the dirty flag
194
+ }
195
+ // BUG CAUGHT: the dirty flag set during run #1 is never consumed because
196
+ // the settle chain (which performs the trailing rerun) was never attached.
197
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2));
198
+ await settle();
199
+ expect(run, 'exactly one trailing rebuild — no loop').toHaveBeenCalledTimes(2);
200
+ });
201
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Write `file` with `contentFor(attempt)`; if `settled` (e.g. the onRebuild
3
+ * promise) does not resolve within ~400ms, re-write with the next attempt's
4
+ * content (guaranteed size/mtime change → fresh inotify event) and keep
5
+ * retrying until `settled` resolves or the test's own vitest timeout fires.
6
+ *
7
+ * `contentFor` MUST embed `attempt` so successive writes differ in length —
8
+ * an identical-bytes rewrite can be coalesced by the OS into no new event.
9
+ */
10
+ export declare function writeUntilSettled(settled: Promise<unknown>, file: string, contentFor: (attempt: number) => string): Promise<void>;
11
+ /**
12
+ * Like `writeUntilSettled` but waits on a polled PREDICATE instead of a
13
+ * promise — for the `vi.waitFor(buildSends === N)` / `logEntries.length > 0`
14
+ * style waiters where the thing being awaited is observable state, not a
15
+ * one-shot resolve. Re-writes the file every ~400ms until `predicate()` is
16
+ * true. Same coalescing-safety contract: predicate must be count-agnostic /
17
+ * monotone-reached (a `>=`, a "saw output", or a stable equality that extra
18
+ * coalesced rebuilds cannot overshoot).
19
+ */
20
+ export declare function writeUntilPredicate(predicate: () => boolean, file: string, contentFor: (attempt: number) => string): Promise<void>;
21
+ //# sourceMappingURL=watch-rebuild.testutil.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watch-rebuild.testutil.d.ts","sourceRoot":"","sources":["../src/watch-rebuild.testutil.ts"],"names":[],"mappings":"AA+BA;;;;;;;;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"}
@@ -0,0 +1,81 @@
1
+ import fs from 'node:fs';
2
+ /**
3
+ * FLAKE-HARDENING HELPER (not a test file — name avoids the `*.test.ts` glob).
4
+ *
5
+ * Why this exists
6
+ * ───────────────
7
+ * The watcher-driven rebuild tests in compile-worker.test.ts /
8
+ * open-project-compile-log.test.ts / compile-worker-leak.test.ts exercise the
9
+ * ONE genuinely-real moving part left after fork is mocked: chokidar's inotify
10
+ * watch. A single `fs.writeFileSync` produces a single inotify `change` event,
11
+ * and under CI load (Linux inotify + a concurrent REAL dmcc compile in
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 (PR #44's 30s
14
+ * timeout at compile-worker.test.ts:297).
15
+ *
16
+ * The fix is NOT a longer timeout (the event is lost, not slow): re-issue the
17
+ * filesystem write — with micro-varied content so size+mtime change and a fresh
18
+ * inotify event is guaranteed — until the rebuild we're waiting on actually
19
+ * lands. The rebuild scheduler (rebuild-scheduler.ts) coalesces every extra
20
+ * write into exactly ONE trailing build, so re-writing is safe for the
21
+ * count-agnostic / `>=` assertions these helpers are applied to.
22
+ *
23
+ * ⚠️ This is flake hardening, NOT goalpost-moving: no assertion is touched.
24
+ * Only apply these helpers where the assertion is count-agnostic or `>=`
25
+ * (re-triggering an extra coalesced rebuild cannot change the outcome). Tests
26
+ * that pin an EXACT rebuild/build-send count must not use them.
27
+ */
28
+ const REWRITE_INTERVAL_MS = 400;
29
+ /**
30
+ * Write `file` with `contentFor(attempt)`; if `settled` (e.g. the onRebuild
31
+ * promise) does not resolve within ~400ms, re-write with the next attempt's
32
+ * content (guaranteed size/mtime change → fresh inotify event) and keep
33
+ * retrying until `settled` resolves or the test's own vitest timeout fires.
34
+ *
35
+ * `contentFor` MUST embed `attempt` so successive writes differ in length —
36
+ * an identical-bytes rewrite can be coalesced by the OS into no new event.
37
+ */
38
+ export async function writeUntilSettled(settled, file, contentFor) {
39
+ let done = false;
40
+ const guard = settled.then(() => { done = true; }, () => { done = true; });
41
+ let attempt = 0;
42
+ // First write happens immediately; subsequent re-writes only if still unsettled.
43
+ for (;;) {
44
+ fs.writeFileSync(file, contentFor(attempt));
45
+ attempt += 1;
46
+ const tick = await Promise.race([
47
+ guard.then(() => 'settled'),
48
+ sleep(REWRITE_INTERVAL_MS).then(() => 'retry'),
49
+ ]);
50
+ if (tick === 'settled' || done)
51
+ break;
52
+ }
53
+ await settled;
54
+ }
55
+ /**
56
+ * Like `writeUntilSettled` but waits on a polled PREDICATE instead of a
57
+ * promise — for the `vi.waitFor(buildSends === N)` / `logEntries.length > 0`
58
+ * style waiters where the thing being awaited is observable state, not a
59
+ * one-shot resolve. Re-writes the file every ~400ms until `predicate()` is
60
+ * true. Same coalescing-safety contract: predicate must be count-agnostic /
61
+ * monotone-reached (a `>=`, a "saw output", or a stable equality that extra
62
+ * coalesced rebuilds cannot overshoot).
63
+ */
64
+ export async function writeUntilPredicate(predicate, file, contentFor) {
65
+ let attempt = 0;
66
+ for (;;) {
67
+ fs.writeFileSync(file, contentFor(attempt));
68
+ attempt += 1;
69
+ const start = Date.now();
70
+ while (Date.now() - start < REWRITE_INTERVAL_MS) {
71
+ if (predicate())
72
+ return;
73
+ await sleep(25);
74
+ }
75
+ if (predicate())
76
+ return;
77
+ }
78
+ }
79
+ function sleep(ms) {
80
+ return new Promise(resolve => setTimeout(resolve, ms));
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dimina-kit/devkit",
3
- "version": "0.1.2-dev.20260612025610",
3
+ "version": "0.1.2-dev.20260615070430",
4
4
  "description": "Development toolkit for Dimina mini-apps with H5 container preview",
5
5
  "keywords": [
6
6
  "dimina",
@@ -52,8 +52,8 @@
52
52
  "eslint": "^10.2.1",
53
53
  "typescript": "5.9.2",
54
54
  "vitest": "^4.1.4",
55
- "@dimina-kit/eslint-config": "0.1.0",
56
- "@dimina-kit/typescript-config": "0.1.0"
55
+ "@dimina-kit/typescript-config": "0.1.0",
56
+ "@dimina-kit/eslint-config": "0.1.0"
57
57
  },
58
58
  "dependencies": {
59
59
  "@dimina/compiler": "1.0.16",