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

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 +1 -1
package/dist/index.d.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import type { CompileLogEntry } from './compile-worker.js';
2
+ export { createRebuildScheduler } from './rebuild-scheduler.js';
3
+ export type { RebuildScheduler } from './rebuild-scheduler.js';
4
+ export { filterDmccLogLine } from './compile-log.js';
5
+ export { createCompileWorker } from './compile-worker.js';
6
+ export type { CompileLogEntry, CompileWorker } from './compile-worker.js';
1
7
  export interface AppInfo {
2
8
  appId: string;
3
9
  name: string;
@@ -19,14 +25,22 @@ export interface OpenProjectOptions {
19
25
  watch?: boolean;
20
26
  onRebuild?: () => void;
21
27
  onBuildError?: (err: unknown) => void;
28
+ /**
29
+ * Per-line dmcc compile log, already filtered through `filterDmccLogLine`
30
+ * (noise stripped, signal kept). Lines come from the forked compile
31
+ * worker's piped stdout/stderr, tagged with their source stream.
32
+ */
33
+ onLog?: (entry: CompileLogEntry) => void;
22
34
  }
23
35
  export declare function openProject(opts: OpenProjectOptions): Promise<ProjectSession>;
24
36
  /**
25
37
  * Watch a project directory and invoke `onChange` whenever a source file is
26
38
  * created, modified, or deleted. Returns a handle whose `close()` stops the
27
- * watcher.
39
+ * watcher and whose `ready` resolves once the initial scan is complete (only
40
+ * changes made after that point are guaranteed to surface).
28
41
  */
29
42
  export declare function createProjectWatcher(projectPath: string, onChange: () => void | Promise<void>): {
30
43
  close: () => Promise<void>;
44
+ ready: Promise<void>;
31
45
  };
32
46
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAiDA,MAAM,WAAW,OAAO;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1B;AAED,MAAM,WAAW,kBAAkB;IAClC,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,oFAAoF;IACpF,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;IACtB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAA;CACrC;AAED,wBAAsB,WAAW,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,cAAc,CAAC,CA4GnF;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CACnC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAClC;IAAE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAmChC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAA;AAE1D,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAC/D,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AACzD,YAAY,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAyCzE,MAAM,WAAW,OAAO;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1B;AAED,MAAM,WAAW,kBAAkB;IAClC,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,oFAAoF;IACpF,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;IACtB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC;;;;OAIG;IACH,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;CACxC;AAED,wBAAsB,WAAW,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,cAAc,CAAC,CAkKnF;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CACnC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAClC;IAAE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CA+CtD"}
package/dist/index.js CHANGED
@@ -6,9 +6,13 @@ import { fileURLToPath } from 'node:url';
6
6
  import { createServer } from 'node:http';
7
7
  import { createRequire } from 'node:module';
8
8
  import chokidar from 'chokidar';
9
+ import { createRebuildScheduler } from './rebuild-scheduler.js';
10
+ import { createCompileWorker } from './compile-worker.js';
11
+ export { createRebuildScheduler } from './rebuild-scheduler.js';
12
+ export { filterDmccLogLine } from './compile-log.js';
13
+ export { createCompileWorker } from './compile-worker.js';
9
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
15
  const require = createRequire(import.meta.url);
11
- const build = require('@dimina/compiler');
12
16
  // esbuild's native binary is shipped inside node_modules, but when packaged
13
17
  // via electron-builder it lives in app.asar. asarUnpack puts the real binary
14
18
  // in app.asar.unpacked — but esbuild computes its binary path from __dirname,
@@ -35,22 +39,31 @@ function getRandomPort() {
35
39
  });
36
40
  }
37
41
  export async function openProject(opts) {
38
- const { projectPath: rawProjectPath, port = 0, sourcemap = false, simulatorDir, containerDir: overrideContainerDir, outputDir, watch = true, onRebuild, onBuildError, } = opts;
42
+ const { projectPath: rawProjectPath, port = 0, sourcemap = false, simulatorDir, containerDir: overrideContainerDir, outputDir, watch = true, onRebuild, onBuildError, onLog, } = opts;
39
43
  const projectPath = path.resolve(rawProjectPath);
40
44
  const buildOptions = { sourcemap };
41
45
  const resolvedPort = port === 0 ? await getRandomPort() : port;
42
46
  const containerDir = overrideContainerDir ?? path.join(__dirname, '..', 'fe', 'dimina-fe-container');
43
- const prevCwd = process.cwd();
44
47
  const resolvedOutputDir = outputDir
45
48
  ?? path.join(os.tmpdir(), 'dimina-kit', createHash('sha1').update(projectPath).digest('hex').slice(0, 12));
46
49
  fs.mkdirSync(resolvedOutputDir, { recursive: true });
50
+ // Compilation runs in a long-lived forked worker — the worker chdirs in
51
+ // its OWN process, so this (host) process never mutates its cwd, and dmcc
52
+ // terminal output arrives on the worker's piped streams (delivered to
53
+ // `onLog` line-by-line after `filterDmccLogLine`).
54
+ const compileWorker = createCompileWorker({ onLog });
55
+ const buildRequest = {
56
+ projectPath,
57
+ outputDir: resolvedOutputDir,
58
+ options: buildOptions,
59
+ };
47
60
  let initialAppInfo;
48
61
  try {
49
- process.chdir(projectPath);
50
- initialAppInfo = (await build(resolvedOutputDir, projectPath, true, buildOptions));
62
+ initialAppInfo = (await compileWorker.build(buildRequest));
51
63
  }
52
- finally {
53
- process.chdir(prevCwd);
64
+ catch (err) {
65
+ await compileWorker.close();
66
+ throw err;
54
67
  }
55
68
  // When the compiler is racing (e.g. opening a project that was just
56
69
  // closed elsewhere in the same Electron process) `build()` can return
@@ -81,25 +94,16 @@ export async function openProject(opts) {
81
94
  }
82
95
  }
83
96
  const sessionApps = initialAppInfo ? [initialAppInfo] : [];
84
- process.env.DIMINA_NO_OPEN_BROWSER = '1';
85
- const fe = await import('../fe/index.js');
86
- const start = fe.start;
87
- const { server, reload } = await start({
88
- port: resolvedPort,
89
- containerDir,
90
- outputDir: resolvedOutputDir,
91
- simulatorDir,
92
- liveReload: true,
93
- sessionApps,
94
- });
95
- let isBuilding = false;
97
+ // Everything after the worker exists is failure-cleaned: if the dev server
98
+ // or the watcher fails to come up, the already-forked compile worker (and a
99
+ // server that already started listening) must be torn down before the error
100
+ // propagates otherwise every failed open leaks a whole compiler process.
101
+ let server = null;
102
+ let reload;
103
+ let watcher = null;
96
104
  async function rebuild() {
97
- if (isBuilding)
98
- return;
99
- isBuilding = true;
100
105
  try {
101
- process.chdir(projectPath);
102
- const rebuilt = (await build(resolvedOutputDir, projectPath, true, buildOptions));
106
+ const rebuilt = (await compileWorker.build(buildRequest));
103
107
  if (rebuilt) {
104
108
  const idx = sessionApps.findIndex(a => a.appId === rebuilt.appId);
105
109
  if (idx === -1)
@@ -113,26 +117,79 @@ export async function openProject(opts) {
113
117
  catch (e) {
114
118
  onBuildError?.(e);
115
119
  }
116
- finally {
117
- process.chdir(prevCwd);
118
- isBuilding = false;
120
+ }
121
+ try {
122
+ process.env.DIMINA_NO_OPEN_BROWSER = '1';
123
+ const fe = await import('../fe/index.js');
124
+ const start = fe.start;
125
+ const started = await start({
126
+ port: resolvedPort,
127
+ containerDir,
128
+ outputDir: resolvedOutputDir,
129
+ simulatorDir,
130
+ liveReload: true,
131
+ sessionApps,
132
+ });
133
+ server = started.server;
134
+ reload = started.reload;
135
+ // Watcher events are routed through the scheduler so a save landing while
136
+ // a build is in flight is never dropped: it coalesces into exactly one
137
+ // trailing rebuild once the current run settles.
138
+ const rebuildScheduler = createRebuildScheduler(rebuild);
139
+ watcher = watch ? createProjectWatcher(projectPath, () => rebuildScheduler.schedule()) : null;
140
+ // Don't resolve until the watcher's initial scan is done: a save landing
141
+ // in the gap between `openProject` resolving and chokidar going live
142
+ // (fsevents stream not yet active on macOS) would otherwise be silently
143
+ // missed — no rebuild for the very first post-open edit. The promise
144
+ // also REJECTS on a pre-ready watcher 'error' (EMFILE, permission loss)
145
+ // so a broken watcher fails the open instead of hanging it forever.
146
+ await watcher?.ready;
147
+ }
148
+ catch (err) {
149
+ try {
150
+ await watcher?.close();
151
+ }
152
+ catch {
153
+ // best-effort — the open is already failing with the primary error
154
+ }
155
+ try {
156
+ await compileWorker.close();
157
+ }
158
+ catch {
159
+ // best-effort — never mask the primary error
119
160
  }
161
+ if (server) {
162
+ ;
163
+ server.closeAllConnections?.();
164
+ const listening = server;
165
+ await new Promise(resolve => listening.close(() => resolve()));
166
+ }
167
+ throw err;
120
168
  }
121
- const watcher = watch ? createProjectWatcher(projectPath, rebuild) : null;
169
+ // `server` is always assigned when the try block completes without throwing
170
+ // — this guard exists only to narrow the type for the close closure.
171
+ if (!server)
172
+ throw new Error('openProject: dev server missing after successful start');
173
+ const liveServer = server;
122
174
  return {
123
175
  appInfo: initialAppInfo ?? { appId: 'unknown', name: path.basename(projectPath), path: projectPath },
124
176
  port: resolvedPort,
125
177
  close: async () => {
126
178
  await watcher?.close();
127
- server.closeAllConnections?.();
128
- await new Promise(resolve => server.close(() => resolve()));
179
+ // Kill the long-lived compile worker and WAIT for the child to be
180
+ // actually dead only kill, never re-fork on a graceful close (a
181
+ // refill here would wedge process teardown).
182
+ await compileWorker.close();
183
+ liveServer.closeAllConnections?.();
184
+ await new Promise(resolve => liveServer.close(() => resolve()));
129
185
  },
130
186
  };
131
187
  }
132
188
  /**
133
189
  * Watch a project directory and invoke `onChange` whenever a source file is
134
190
  * created, modified, or deleted. Returns a handle whose `close()` stops the
135
- * watcher.
191
+ * watcher and whose `ready` resolves once the initial scan is complete (only
192
+ * changes made after that point are guaranteed to surface).
136
193
  */
137
194
  export function createProjectWatcher(projectPath, onChange) {
138
195
  // Ignore dotfiles/dot-directories that live *inside* the project (.git,
@@ -166,7 +223,19 @@ export function createProjectWatcher(projectPath, onChange) {
166
223
  watcher.on('add', () => { void onChange(); });
167
224
  watcher.on('change', () => { void onChange(); });
168
225
  watcher.on('unlink', () => { void onChange(); });
226
+ // A watcher 'error' before the initial scan completes (EMFILE, permission
227
+ // loss, …) must REJECT `ready`: resolving only on 'ready' would leave
228
+ // `await watcher.ready` — and the whole openProject — hung forever. The
229
+ // listener also doubles as the EventEmitter unhandled-'error' guard. A
230
+ // post-ready 'error' makes the reject a settled-promise no-op.
231
+ const ready = new Promise((resolve, reject) => {
232
+ watcher.once('ready', () => resolve());
233
+ watcher.on('error', (err) => {
234
+ reject(err instanceof Error ? err : new Error(String(err)));
235
+ });
236
+ });
169
237
  return {
170
238
  close: () => watcher.close(),
239
+ ready,
171
240
  };
172
241
  }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=open-project-cleanup.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"open-project-cleanup.test.d.ts","sourceRoot":"","sources":["../src/open-project-cleanup.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,176 @@
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
+ /**
9
+ * CODEX-REVIEW REGRESSION WAVE (fix/editor-hot-reload-and-simulator-leftovers)
10
+ * — failing regression tests for review findings M4 / M6: resource cleanup
11
+ * when `openProject` fails AFTER the compile worker was already forked.
12
+ *
13
+ * M4 the worker is created before the dev server starts; a server-start
14
+ * failure propagates out of openProject but never kills the worker — a
15
+ * leaked compiler process per failed open.
16
+ * M6 `createProjectWatcher().ready` only resolves on chokidar's 'ready';
17
+ * a watcher 'error' (EMFILE, permission loss, …) before ready leaves
18
+ * `await watcher?.ready` — and therefore openProject — hung forever,
19
+ * leaking the worker AND the already-listening dev server.
20
+ *
21
+ * Harness: same fake-fork pattern as compile-worker.test.ts, plus this file
22
+ * mocks the fe dev-server module and chokidar so the failure point of each
23
+ * test is injectable. All mocks are per-file (vi.mock is hoisted), so these
24
+ * tests live in their own file instead of the main orchestration suite.
25
+ */
26
+ const mocks = vi.hoisted(() => ({
27
+ fork: vi.fn(),
28
+ feStart: vi.fn(),
29
+ watch: vi.fn(),
30
+ }));
31
+ vi.mock('node:child_process', async (importOriginal) => {
32
+ const actual = await importOriginal();
33
+ return { ...actual, fork: mocks.fork, default: { ...actual, fork: mocks.fork } };
34
+ });
35
+ vi.mock('child_process', async (importOriginal) => {
36
+ const actual = await importOriginal();
37
+ return { ...actual, fork: mocks.fork, default: { ...actual, fork: mocks.fork } };
38
+ });
39
+ // src/index.ts dynamically imports '../fe/index.js' and calls its `start`.
40
+ vi.mock('../fe/index.js', () => ({ start: mocks.feStart }));
41
+ vi.mock('chokidar', () => ({
42
+ default: { watch: mocks.watch },
43
+ watch: mocks.watch,
44
+ }));
45
+ /** Minimal auto-responding fake compile worker (kill spy is the assertion). */
46
+ class FakeChild extends EventEmitter {
47
+ stdout = new PassThrough();
48
+ stderr = new PassThrough();
49
+ connected = true;
50
+ pid = 4242;
51
+ send = vi.fn((msg) => {
52
+ const m = msg;
53
+ if (m && m.cmd === 'build') {
54
+ queueMicrotask(() => {
55
+ if (!this.connected)
56
+ return;
57
+ this.emit('message', {
58
+ type: 'result',
59
+ appInfo: { appId: 'cleanup_app_001', name: 'cleanup-app', path: String(m.projectPath ?? '') },
60
+ });
61
+ });
62
+ }
63
+ return true;
64
+ });
65
+ kill = vi.fn(() => {
66
+ this.connected = false;
67
+ queueMicrotask(() => this.emit('exit', null, 'SIGTERM'));
68
+ return true;
69
+ });
70
+ }
71
+ /** chokidar stand-in: an EventEmitter with the watcher surface index.ts uses. */
72
+ class FakeWatcher extends EventEmitter {
73
+ close = vi.fn(async () => { });
74
+ }
75
+ function makeFakeFe() {
76
+ const server = {
77
+ close: vi.fn((cb) => {
78
+ cb?.();
79
+ return server;
80
+ }),
81
+ closeAllConnections: vi.fn(),
82
+ };
83
+ return { server, reload: vi.fn() };
84
+ }
85
+ const children = [];
86
+ const watchers = [];
87
+ const feInstances = [];
88
+ const cleanupRoots = [];
89
+ beforeEach(() => {
90
+ children.length = 0;
91
+ watchers.length = 0;
92
+ feInstances.length = 0;
93
+ mocks.fork.mockReset();
94
+ mocks.fork.mockImplementation(() => {
95
+ const child = new FakeChild();
96
+ children.push(child);
97
+ return child;
98
+ });
99
+ mocks.feStart.mockReset();
100
+ mocks.feStart.mockImplementation(async () => {
101
+ const fe = makeFakeFe();
102
+ feInstances.push(fe);
103
+ return fe;
104
+ });
105
+ mocks.watch.mockReset();
106
+ mocks.watch.mockImplementation(() => {
107
+ const watcher = new FakeWatcher();
108
+ watchers.push(watcher);
109
+ // Well-behaved default: initial scan completes promptly.
110
+ queueMicrotask(() => watcher.emit('ready'));
111
+ return watcher;
112
+ });
113
+ });
114
+ afterEach(() => {
115
+ for (const root of cleanupRoots.splice(0)) {
116
+ fs.rmSync(root, { recursive: true, force: true });
117
+ }
118
+ });
119
+ function makeFixture() {
120
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-open-cleanup-'));
121
+ cleanupRoots.push(root);
122
+ fs.writeFileSync(path.join(root, 'project.config.json'), JSON.stringify({ appid: 'cleanup_app_001', projectname: 'cleanup-app' }));
123
+ return root;
124
+ }
125
+ function sleep(ms) {
126
+ return new Promise(resolve => setTimeout(resolve, ms));
127
+ }
128
+ describe('openProject failure cleanup — no leaked compile worker (codex M4/M6)', () => {
129
+ it('M4: a dev-server start failure AFTER the worker was forked rejects openProject AND kills the worker', async () => {
130
+ const root = makeFixture();
131
+ mocks.feStart.mockReset();
132
+ mocks.feStart.mockRejectedValue(new Error('listen EADDRINUSE: address already in use'));
133
+ await expect(devkit.openProject({
134
+ projectPath: root,
135
+ watch: false,
136
+ outputDir: path.join(root, '.out'),
137
+ })).rejects.toThrow(/EADDRINUSE/);
138
+ expect(children.length, 'precondition: the compile worker is forked BEFORE the dev server starts').toBe(1);
139
+ expect(children[0].kill, 'a server-start failure must kill the already-forked compile worker — today the error propagates but the '
140
+ + 'worker (a whole compiler process) is leaked on every failed open').toHaveBeenCalled();
141
+ }, 15_000);
142
+ it("M6: a watcher 'error' before 'ready' rejects openProject within a bounded time and kills the worker (no eternal hang)", async () => {
143
+ const root = makeFixture();
144
+ mocks.watch.mockReset();
145
+ mocks.watch.mockImplementation(() => {
146
+ const watcher = new FakeWatcher();
147
+ watchers.push(watcher);
148
+ setTimeout(() => {
149
+ // Today nothing listens for 'error' (EventEmitter would throw on
150
+ // an unhandled 'error' event) — the swallow keeps THIS test
151
+ // deterministic; the bug is that openProject then hangs forever.
152
+ try {
153
+ watcher.emit('error', new Error('EMFILE: too many open files, watch'));
154
+ }
155
+ catch {
156
+ // no 'error' listener registered — the exact gap under test
157
+ }
158
+ }, 10);
159
+ return watcher;
160
+ });
161
+ const openPromise = devkit.openProject({
162
+ projectPath: root,
163
+ watch: true,
164
+ outputDir: path.join(root, '.out'),
165
+ });
166
+ const outcome = await Promise.race([
167
+ openPromise.then(() => 'resolved', () => 'rejected'),
168
+ sleep(3000).then(() => 'hung'),
169
+ ]);
170
+ expect(outcome, "a watcher 'error' before the initial scan completes must REJECT openProject — `await watcher.ready` only "
171
+ + "listens for 'ready', so today the open hangs forever").toBe('rejected');
172
+ expect(children.length, 'precondition: the compile worker was forked before the watcher failed').toBe(1);
173
+ expect(children[0].kill, 'the watcher-error rejection must clean up the already-created resources: the compile worker must be killed').toHaveBeenCalled();
174
+ expect(feInstances[0]?.server.close, '…and the already-listening dev server must be closed too').toHaveBeenCalled();
175
+ }, 15_000);
176
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=open-project-compile-log.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"open-project-compile-log.test.d.ts","sourceRoot":"","sources":["../src/open-project-compile-log.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,174 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import * as devkit from './index.js';
6
+ import { writeUntilSettled } from './watch-rebuild.testutil.js';
7
+ const cleanupRoots = [];
8
+ const openSessions = [];
9
+ afterEach(async () => {
10
+ for (const session of openSessions.splice(0)) {
11
+ try {
12
+ await session.close();
13
+ }
14
+ catch {
15
+ // best-effort teardown
16
+ }
17
+ }
18
+ for (const root of cleanupRoots.splice(0)) {
19
+ fs.rmSync(root, { recursive: true, force: true });
20
+ }
21
+ });
22
+ function makeFixture(opts = {}) {
23
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-compile-log-'));
24
+ cleanupRoots.push(root);
25
+ const write = (rel, content) => {
26
+ const target = path.join(root, rel);
27
+ fs.mkdirSync(path.dirname(target), { recursive: true });
28
+ fs.writeFileSync(target, content);
29
+ };
30
+ write('project.config.json', JSON.stringify({ appid: 'fixture_app_001', projectname: 'fixture-app' }));
31
+ write('app.json', JSON.stringify({ pages: ['pages/index/index'] }));
32
+ write('app.js', 'App({})\n');
33
+ write('app.wxss', 'page { font-size: 14px; }\n');
34
+ write('pages/index/index.json', '{}\n');
35
+ write('pages/index/index.js', opts.brokenIndexJs
36
+ // Unbalanced brace + bad tokens — same corruption shape the spike used
37
+ // to capture error.stderr.txt (esbuild transform failure).
38
+ ? 'Page({ data: { msg: "hi" } })\nconst broken = {{{ ;;; <<<\n'
39
+ : 'Page({ data: { msg: "hi" } })\n');
40
+ write('pages/index/index.wxml', '<view>{{msg}}</view>\n');
41
+ write('pages/index/index.wxss', '.x { color: red; }\n');
42
+ return root;
43
+ }
44
+ /** Noise patterns that must NEVER reach onLog (RESULTS.md §③ DROP rules). */
45
+ function expectNoNoise(texts) {
46
+ for (const text of texts) {
47
+ expect(text.length, 'onLog must never receive an empty line').toBeGreaterThan(0);
48
+ expect(text, 'logo / box-drawing banner lines must be filtered out').not.toMatch(/^[█╔╗╚╝═║\s]+$/);
49
+ expect(text, '`❯ ` task-start lines must be filtered out').not.toMatch(/^❯ /);
50
+ expect(text, '`› [██░░] %` progress-bar lines must be filtered out').not.toMatch(/^› \[/);
51
+ expect(text, 'fe-server banner must be filtered out').not.toMatch(/^Server is running/);
52
+ expect(text, 'fe-server banner must be filtered out').not.toMatch(/^Press Ctrl\+C/);
53
+ expect(text, 'stack-trace frames must be filtered out').not.toMatch(/^\s+at /);
54
+ }
55
+ }
56
+ describe('openProject onLog — first compile (integration, real dmcc)', () => {
57
+ it('streams filtered compile-error lines to onLog when the first compile fails', async () => {
58
+ const root = makeFixture({ brokenIndexJs: true });
59
+ const entries = [];
60
+ const logOpts = { onLog: (entry) => entries.push(entry) };
61
+ const session = await devkit.openProject({
62
+ projectPath: root,
63
+ watch: false,
64
+ outputDir: path.join(root, '.out'),
65
+ ...logOpts,
66
+ });
67
+ openSessions.push(session);
68
+ expect(entries.length, 'openProject must wire opts.onLog through withCapturedStdio around the first build() — no lines were captured').toBeGreaterThan(0);
69
+ const texts = entries.map(entry => entry.text);
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
+ expect(texts.some(text => /^✖ /.test(text)), '`✖ …[FAILED: …]` listr failure lines must be kept').toBe(true);
72
+ expect(texts.some(text => /编译出错: /.test(text)), 'the `… 编译出错: …` summary line must be kept').toBe(true);
73
+ expectNoNoise(texts);
74
+ for (const entry of entries) {
75
+ expect(['stdout', 'stderr']).toContain(entry.stream);
76
+ expect(typeof entry.text).toBe('string');
77
+ }
78
+ // dmcc emits the esbuild failure on stderr (error.stderr.txt line 1) —
79
+ // the stream tag must say so, so the panel can style it as an error.
80
+ const failure = entries.find(entry => /esbuild 转换失败/.test(entry.text));
81
+ expect(failure?.stream).toBe('stderr');
82
+ }, 60_000);
83
+ it('streams filtered stage lines (✔ …) to onLog on a successful first compile', async () => {
84
+ const root = makeFixture();
85
+ const entries = [];
86
+ const logOpts = { onLog: (entry) => entries.push(entry) };
87
+ const session = await devkit.openProject({
88
+ projectPath: root,
89
+ watch: false,
90
+ outputDir: path.join(root, '.out'),
91
+ ...logOpts,
92
+ });
93
+ openSessions.push(session);
94
+ expect(entries.length, 'a successful compile must also stream its stage lines through onLog').toBeGreaterThan(0);
95
+ const texts = entries.map(entry => entry.text);
96
+ expect(texts.some(text => /^✔ /.test(text)), '`✔ ` stage-completion lines must be kept (e.g. ✔ 输出编译产物)').toBe(true);
97
+ expectNoNoise(texts);
98
+ // Stage lines are stdout (success.stdout.txt) — stream tags must match.
99
+ const stage = entries.find(entry => /^✔ /.test(entry.text));
100
+ expect(stage?.stream).toBe('stdout');
101
+ }, 60_000);
102
+ });
103
+ describe('openProject onLog — watcher rebuild (integration, real dmcc)', () => {
104
+ it('streams rebuild lines to onLog when the file watcher triggers a rebuild', async () => {
105
+ const root = makeFixture();
106
+ const entries = [];
107
+ const logOpts = { onLog: (entry) => entries.push(entry) };
108
+ let resolveRebuilt = () => { };
109
+ const rebuilt = new Promise((resolve) => {
110
+ resolveRebuilt = resolve;
111
+ });
112
+ const session = await devkit.openProject({
113
+ projectPath: root,
114
+ watch: true,
115
+ outputDir: path.join(root, '.out'),
116
+ // rebuild() invokes onRebuild AFTER build() resolves, so by the time
117
+ // this fires every rebuild log line has already been delivered.
118
+ onRebuild: () => resolveRebuilt(),
119
+ ...logOpts,
120
+ });
121
+ openSessions.push(session);
122
+ // Only count lines captured by the REBUILD build() call.
123
+ entries.length = 0;
124
+ // Count-agnostic (entries.length > 0): re-write until the rebuild lands so
125
+ // a dropped inotify event under CI load can't hang `await rebuilt`.
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 be wrapped with withCapturedStdio — no rebuild lines reached onLog').toBeGreaterThan(0);
128
+ const texts = entries.map(entry => entry.text);
129
+ expect(texts.some(text => /^✔ /.test(text)), 'rebuild stage-completion lines must be kept').toBe(true);
130
+ expectNoNoise(texts);
131
+ }, 60_000);
132
+ });
133
+ describe('openProject without onLog — zero-overhead contract (regression guard, green today)', () => {
134
+ it('never swaps process.stdout/stderr.write nor touches isTTY during the build', async () => {
135
+ const root = makeFixture();
136
+ const stdoutM = process.stdout;
137
+ const stderrM = process.stderr;
138
+ const before = {
139
+ outWrite: stdoutM.write,
140
+ errWrite: stderrM.write,
141
+ outTTY: stdoutM.isTTY,
142
+ errTTY: stderrM.isTTY,
143
+ };
144
+ let writeSwapped = false;
145
+ let ttyTouched = false;
146
+ let samples = 0;
147
+ // The build awaits worker threads / esbuild IPC, so the event loop turns
148
+ // freely during the multi-second compile — the sampler reliably lands
149
+ // inside the build window.
150
+ const timer = setInterval(() => {
151
+ samples += 1;
152
+ if (stdoutM.write !== before.outWrite || stderrM.write !== before.errWrite) {
153
+ writeSwapped = true;
154
+ }
155
+ if (stdoutM.isTTY !== before.outTTY || stderrM.isTTY !== before.errTTY) {
156
+ ttyTouched = true;
157
+ }
158
+ }, 1);
159
+ try {
160
+ const session = await devkit.openProject({
161
+ projectPath: root,
162
+ watch: false,
163
+ outputDir: path.join(root, '.out'),
164
+ });
165
+ openSessions.push(session);
166
+ }
167
+ finally {
168
+ clearInterval(timer);
169
+ }
170
+ expect(samples, 'sampler must have observed the build window').toBeGreaterThan(20);
171
+ expect(writeSwapped, 'without onLog the capture wrapper must NOT be engaged — write was swapped during the build').toBe(false);
172
+ expect(ttyTouched, 'without onLog isTTY must stay untouched during the build').toBe(false);
173
+ }, 60_000);
174
+ });
@@ -0,0 +1,24 @@
1
+ export interface RebuildScheduler {
2
+ /** Request a rebuild. Coalesces with any in-flight or already-pending run. */
3
+ schedule: () => void;
4
+ }
5
+ /**
6
+ * Serialize rebuild runs with a dirty-flag + trailing rerun, replacing the old
7
+ * `if (isBuilding) return` early-exit in `openProject`'s rebuild loop, which
8
+ * silently DROPPED any watcher event that landed while a build was in flight
9
+ * (a save made during the ~1-2s compile window never produced a rebuild — the
10
+ * simulator stayed stale until the user saved again).
11
+ *
12
+ * Semantics:
13
+ * - `schedule()` while idle starts `run` immediately.
14
+ * - `schedule()` while `run` is in flight never starts a concurrent run
15
+ * (`build()` chdir()s into the project — concurrency would corrupt cwd);
16
+ * it marks the state dirty instead.
17
+ * - When the in-flight run settles and the state is dirty, exactly one
18
+ * trailing run starts: N saves during one build coalesce into 1 rerun.
19
+ * The trailing run is itself schedulable-against, recursively.
20
+ * - A rejecting run neither wedges the scheduler nor drops a pending dirty
21
+ * flag — `run` is expected to do its own error reporting (`onBuildError`).
22
+ */
23
+ export declare function createRebuildScheduler(run: () => Promise<void>): RebuildScheduler;
24
+ //# sourceMappingURL=rebuild-scheduler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rebuild-scheduler.d.ts","sourceRoot":"","sources":["../src/rebuild-scheduler.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAChC,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,IAAI,CAAA;CACpB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,gBAAgB,CAwCjF"}