@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.
- package/README.md +5 -3
- package/dist/compile-log.d.ts +16 -0
- package/dist/compile-log.d.ts.map +1 -0
- package/dist/compile-log.js +42 -0
- package/dist/compile-log.test.d.ts +2 -0
- package/dist/compile-log.test.d.ts.map +1 -0
- package/dist/compile-log.test.js +134 -0
- package/dist/compile-worker-entry.d.ts +47 -0
- package/dist/compile-worker-entry.d.ts.map +1 -0
- package/dist/compile-worker-entry.js +117 -0
- package/dist/compile-worker-entry.test.d.ts +2 -0
- package/dist/compile-worker-entry.test.d.ts.map +1 -0
- package/dist/compile-worker-entry.test.js +247 -0
- package/dist/compile-worker-leak.test.d.ts +2 -0
- package/dist/compile-worker-leak.test.d.ts.map +1 -0
- package/dist/compile-worker-leak.test.js +284 -0
- package/dist/compile-worker.d.ts +33 -0
- package/dist/compile-worker.d.ts.map +1 -0
- package/dist/compile-worker.js +213 -0
- package/dist/compile-worker.test.d.ts +2 -0
- package/dist/compile-worker.test.d.ts.map +1 -0
- package/dist/compile-worker.test.js +791 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +100 -31
- package/dist/open-project-cleanup.test.d.ts +2 -0
- package/dist/open-project-cleanup.test.d.ts.map +1 -0
- package/dist/open-project-cleanup.test.js +176 -0
- package/dist/open-project-compile-log.test.d.ts +2 -0
- package/dist/open-project-compile-log.test.d.ts.map +1 -0
- package/dist/open-project-compile-log.test.js +174 -0
- package/dist/rebuild-scheduler.d.ts +24 -0
- package/dist/rebuild-scheduler.d.ts.map +1 -0
- package/dist/rebuild-scheduler.js +58 -0
- package/dist/rebuild-scheduler.test.d.ts +2 -0
- package/dist/rebuild-scheduler.test.d.ts.map +1 -0
- package/dist/rebuild-scheduler.test.js +201 -0
- package/dist/watch-rebuild.testutil.d.ts +21 -0
- package/dist/watch-rebuild.testutil.d.ts.map +1 -0
- package/dist/watch-rebuild.testutil.js +81 -0
- package/package.json +3 -3
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
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
|
-
|
|
50
|
-
initialAppInfo = (await build(resolvedOutputDir, projectPath, true, buildOptions));
|
|
62
|
+
initialAppInfo = (await compileWorker.build(buildRequest));
|
|
51
63
|
}
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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 @@
|
|
|
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 @@
|
|
|
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"}
|