@dimina-kit/devtools 0.4.0-dev.20260718085557 → 0.4.0-dev.20260718143821
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/main/app/app.d.ts +10 -1
- package/dist/main/app/app.js +11 -1
- package/dist/main/app/lifecycle.d.ts +12 -1
- package/dist/main/app/lifecycle.js +18 -1
- package/dist/main/index.bundle.js +326 -35
- package/dist/main/index.js +49 -1
- package/dist/main/runtime/devtools-backend-before-quit.harness.d.ts +61 -0
- package/dist/main/runtime/devtools-backend-before-quit.harness.js +356 -0
- package/dist/main/runtime/devtools-backend.js +49 -4
- package/dist/main/services/mcp/server.js +24 -2
- package/dist/main/services/projects/create-project-service.js +10 -3
- package/dist/main/services/update/github-release-checker.js +22 -3
- package/dist/main/services/update/update-manager.d.ts +4 -1
- package/dist/main/services/update/update-manager.js +30 -5
- package/dist/native-host/common/common.js +154 -58
- package/dist/native-host/container/pageFrame.css +1 -1
- package/dist/native-host/render/render.js +6578 -3291
- package/dist/native-host/service/service.js +2 -2
- package/dist/renderer/assets/{index-U9F8MrtV.js → index-DfDIvgvK.js} +2 -2
- package/dist/renderer/entries/main/index.html +1 -1
- package/dist/service-host/sync-impls/menu-button-geometry.js +15 -5
- package/dist/service-host/sync-impls/menu-button.js +5 -3
- package/dist/simulator/assets/device-shell-Cs-rmP1f.js +2 -0
- package/dist/simulator/assets/device-shell-DOy53Y0s.css +1 -0
- package/dist/simulator/assets/{simulator-Cy8c-eA2.js → simulator-BG-5CADK.js} +3 -3
- package/dist/simulator/device-shell/menu-button-geometry.js +15 -5
- package/dist/simulator/simulator.html +1 -1
- package/package.json +6 -6
- package/dist/simulator/assets/device-shell-C-HZY3bL.css +0 -1
- package/dist/simulator/assets/device-shell-Cu1ZPLfe.js +0 -2
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { WorkbenchAppConfig } from '../../shared/types.js';
|
|
2
|
+
import type { RuntimeBackend } from '@dimina-kit/electron-deck';
|
|
3
|
+
declare const stubs: {
|
|
4
|
+
handlers: Map<string, (...args: unknown[]) => unknown>;
|
|
5
|
+
projectsJsonPath: string;
|
|
6
|
+
getProjectsJson(): string | null;
|
|
7
|
+
setProjectsJson(v: string | null): void;
|
|
8
|
+
projectsWithAppJson: Set<string>;
|
|
9
|
+
makeEmitter: () => {
|
|
10
|
+
listeners: Record<string, Set<(...args: unknown[]) => unknown>>;
|
|
11
|
+
on(event: string, fn: (...args: unknown[]) => unknown): /*elided*/ any;
|
|
12
|
+
once(event: string, fn: (...args: unknown[]) => unknown): /*elided*/ any;
|
|
13
|
+
off(event: string, fn: (...args: unknown[]) => unknown): /*elided*/ any;
|
|
14
|
+
removeListener(event: string, fn: (...args: unknown[]) => unknown): /*elided*/ any;
|
|
15
|
+
emit(event: string, ...args: unknown[]): void;
|
|
16
|
+
};
|
|
17
|
+
reset: () => void;
|
|
18
|
+
};
|
|
19
|
+
declare const devkitStubs: {
|
|
20
|
+
sessionClose: import("vitest").Mock<() => Promise<void>>;
|
|
21
|
+
};
|
|
22
|
+
declare const viewManagerStubs: {
|
|
23
|
+
createdManagers: Array<{
|
|
24
|
+
disposeAll: () => void;
|
|
25
|
+
}>;
|
|
26
|
+
};
|
|
27
|
+
export { stubs, devkitStubs, viewManagerStubs };
|
|
28
|
+
/**
|
|
29
|
+
* Shared per-test setup both `devtools-backend-before-quit.test.ts` and
|
|
30
|
+
* `devtools-backend-shutdown-race.test.ts` need identically: reset modules
|
|
31
|
+
* (fresh `let`-scoped module-level state in `devtools-backend.ts` each
|
|
32
|
+
* test), reset the mock harness above, and dynamically re-import `electron`
|
|
33
|
+
* + `createDevtoolsBackend` — dynamic, not a static top-level import,
|
|
34
|
+
* because it must happen AFTER `vi.resetModules()` picks up a fresh module
|
|
35
|
+
* instance each test. Registers its own `beforeEach`; call once per
|
|
36
|
+
* describing test file. Returns a mutable state object (not individual
|
|
37
|
+
* values) because `beforeEach` reassigns its fields once per test, after
|
|
38
|
+
* this function itself has already returned.
|
|
39
|
+
*/
|
|
40
|
+
export interface BackendTestState {
|
|
41
|
+
createDevtoolsBackend: typeof import('./devtools-backend.js').createDevtoolsBackend;
|
|
42
|
+
electron: typeof import('electron');
|
|
43
|
+
}
|
|
44
|
+
export declare function registerBackendTestLifecycle(): BackendTestState;
|
|
45
|
+
/**
|
|
46
|
+
* Shared setup for the two tests (one in `devtools-backend-before-quit.test.ts`,
|
|
47
|
+
* one in `devtools-backend-shutdown-race.test.ts`) that need to observe
|
|
48
|
+
* `assemble()` PAUSED mid-flight, inside a still-pending `config.onSetup`:
|
|
49
|
+
* builds the backend with a gated `onSetup`, runs `beforeReady`/`assemble`,
|
|
50
|
+
* and waits until the assembled `ViewManager` is actually reachable (proof
|
|
51
|
+
* `createDevtoolsRuntime` has progressed past instance construction) before
|
|
52
|
+
* handing control back — the caller then fires whatever quit-path event it's
|
|
53
|
+
* testing while `onSetup` is still gated, and is responsible for calling
|
|
54
|
+
* `releaseOnSetup()` + awaiting `assemblePromise` itself.
|
|
55
|
+
*/
|
|
56
|
+
export declare function startAssemblingWithGatedOnSetup(createDevtoolsBackend: BackendTestState['createDevtoolsBackend'], extraConfig?: Omit<WorkbenchAppConfig, 'onSetup'>): Promise<{
|
|
57
|
+
backend: RuntimeBackend;
|
|
58
|
+
assemblePromise: Promise<void>;
|
|
59
|
+
releaseOnSetup: () => void;
|
|
60
|
+
}>;
|
|
61
|
+
//# sourceMappingURL=devtools-backend-before-quit.harness.d.ts.map
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared vitest mock harness for `devtools-backend-before-quit.test.ts` and
|
|
3
|
+
* `devtools-backend-shutdown-race.test.ts` — both drive the REAL
|
|
4
|
+
* `createDevtoolsBackend`/`createDevtoolsRuntime` against this same
|
|
5
|
+
* electron/fs/`@dimina-kit/devkit`/view-manager mock set (lifted from
|
|
6
|
+
* `quit-flag-onclose.test.ts`), split out to keep each test file under the
|
|
7
|
+
* repo's 500-line file-length ratchet without duplicating ~350 lines of
|
|
8
|
+
* mock setup between them.
|
|
9
|
+
*
|
|
10
|
+
* `vi.mock(...)` calls here run once per importing test file's own isolated
|
|
11
|
+
* module graph (vitest scopes mocking per test file), so importing this
|
|
12
|
+
* module has the same effect as if each call were written directly in the
|
|
13
|
+
* importing file — both test files' `beforeEach` still dynamically
|
|
14
|
+
* `await import('./devtools-backend.js')` AFTER `vi.resetModules()`, well
|
|
15
|
+
* after this module's mock registrations have already run.
|
|
16
|
+
*/
|
|
17
|
+
import { beforeEach, expect, vi } from 'vitest';
|
|
18
|
+
// ── Hoisted stub state (lifted from quit-flag-onclose.test.ts) ────────────
|
|
19
|
+
// `export const x = vi.hoisted(...)` is rejected by vitest's hoisting
|
|
20
|
+
// transform ("Cannot export hoisted variable") — declare then export below.
|
|
21
|
+
const stubs = vi.hoisted(() => {
|
|
22
|
+
const handlers = new Map();
|
|
23
|
+
const projectsJsonPath = '/tmp/dimina-test-userdata/dimina-projects.json';
|
|
24
|
+
let projectsJsonContent = null;
|
|
25
|
+
const projectsWithAppJson = new Set();
|
|
26
|
+
function makeEmitter() {
|
|
27
|
+
const listeners = {};
|
|
28
|
+
return {
|
|
29
|
+
listeners,
|
|
30
|
+
on(event, fn) {
|
|
31
|
+
;
|
|
32
|
+
(listeners[event] ??= new Set()).add(fn);
|
|
33
|
+
return this;
|
|
34
|
+
},
|
|
35
|
+
once(event, fn) {
|
|
36
|
+
const wrap = (...args) => {
|
|
37
|
+
listeners[event]?.delete(wrap);
|
|
38
|
+
return fn(...args);
|
|
39
|
+
};
|
|
40
|
+
(listeners[event] ??= new Set()).add(wrap);
|
|
41
|
+
return this;
|
|
42
|
+
},
|
|
43
|
+
off(event, fn) {
|
|
44
|
+
listeners[event]?.delete(fn);
|
|
45
|
+
return this;
|
|
46
|
+
},
|
|
47
|
+
removeListener(event, fn) {
|
|
48
|
+
listeners[event]?.delete(fn);
|
|
49
|
+
return this;
|
|
50
|
+
},
|
|
51
|
+
emit(event, ...args) {
|
|
52
|
+
for (const fn of [...(listeners[event] ?? [])])
|
|
53
|
+
fn(...args);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function reset() {
|
|
58
|
+
handlers.clear();
|
|
59
|
+
projectsJsonContent = null;
|
|
60
|
+
projectsWithAppJson.clear();
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
handlers,
|
|
64
|
+
projectsJsonPath,
|
|
65
|
+
getProjectsJson() {
|
|
66
|
+
return projectsJsonContent;
|
|
67
|
+
},
|
|
68
|
+
setProjectsJson(v) {
|
|
69
|
+
projectsJsonContent = v;
|
|
70
|
+
},
|
|
71
|
+
projectsWithAppJson,
|
|
72
|
+
makeEmitter,
|
|
73
|
+
reset,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
// ── electron stub ────────────────────────────────────────────────────────
|
|
77
|
+
// `app` is a live emitter so `registerAppLifecycle` can attach a
|
|
78
|
+
// `before-quit` listener that the test fires directly.
|
|
79
|
+
vi.mock('electron', () => {
|
|
80
|
+
const ipcEmitter = stubs.makeEmitter();
|
|
81
|
+
const ipcMain = {
|
|
82
|
+
...ipcEmitter,
|
|
83
|
+
handle: vi.fn((channel, fn) => {
|
|
84
|
+
stubs.handlers.set(channel, fn);
|
|
85
|
+
}),
|
|
86
|
+
removeHandler: vi.fn((channel) => {
|
|
87
|
+
stubs.handlers.delete(channel);
|
|
88
|
+
}),
|
|
89
|
+
on: vi.fn((event, fn) => ipcEmitter.on(event, fn)),
|
|
90
|
+
removeListener: vi.fn((event, fn) => ipcEmitter.removeListener(event, fn)),
|
|
91
|
+
};
|
|
92
|
+
const appEmitter = stubs.makeEmitter();
|
|
93
|
+
const app = {
|
|
94
|
+
...appEmitter,
|
|
95
|
+
isPackaged: true,
|
|
96
|
+
whenReady: vi.fn(() => Promise.resolve()),
|
|
97
|
+
getPath: vi.fn(() => '/tmp/dimina-test-userdata'),
|
|
98
|
+
quit: vi.fn(),
|
|
99
|
+
setName: vi.fn(),
|
|
100
|
+
commandLine: {
|
|
101
|
+
getSwitchValue: vi.fn(() => ''),
|
|
102
|
+
appendSwitch: vi.fn(),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
// `WebContents` and `BrowserWindow` below both wrap a `stubs.makeEmitter()`
|
|
106
|
+
// and need the SAME on/once/off/removeListener/emit delegation (the
|
|
107
|
+
// emitter's own methods use `this` internally, so a plain property copy
|
|
108
|
+
// would call them with the wrong receiver — each needs `.bind(em)`). Shared
|
|
109
|
+
// base class instead of re-declaring the same 5 bound fields in both.
|
|
110
|
+
class EmittingStub {
|
|
111
|
+
em = stubs.makeEmitter();
|
|
112
|
+
on = this.em.on.bind(this.em);
|
|
113
|
+
once = this.em.once.bind(this.em);
|
|
114
|
+
off = this.em.off.bind(this.em);
|
|
115
|
+
removeListener = this.em.removeListener.bind(this.em);
|
|
116
|
+
emit = this.em.emit.bind(this.em);
|
|
117
|
+
}
|
|
118
|
+
class WebContents extends EmittingStub {
|
|
119
|
+
destroyed = false;
|
|
120
|
+
id = Math.floor(Math.random() * 1e6);
|
|
121
|
+
send = vi.fn();
|
|
122
|
+
isDestroyed = () => this.destroyed;
|
|
123
|
+
openDevTools = vi.fn();
|
|
124
|
+
closeDevTools = vi.fn();
|
|
125
|
+
setDevToolsWebContents = vi.fn();
|
|
126
|
+
setWindowOpenHandler = vi.fn();
|
|
127
|
+
loadFile = vi.fn(() => Promise.resolve());
|
|
128
|
+
loadURL = vi.fn(() => Promise.resolve());
|
|
129
|
+
executeJavaScript = vi.fn(() => Promise.resolve(undefined));
|
|
130
|
+
reload = vi.fn();
|
|
131
|
+
getType = () => 'window';
|
|
132
|
+
getURL = () => '';
|
|
133
|
+
debugger = {
|
|
134
|
+
attach: vi.fn(),
|
|
135
|
+
detach: vi.fn(),
|
|
136
|
+
isAttached: () => false,
|
|
137
|
+
on: vi.fn(),
|
|
138
|
+
removeListener: vi.fn(),
|
|
139
|
+
sendCommand: vi.fn(() => Promise.resolve({ entries: [] })),
|
|
140
|
+
};
|
|
141
|
+
close = vi.fn(() => {
|
|
142
|
+
this.destroyed = true;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
class WebContentsView {
|
|
146
|
+
webContents = new WebContents();
|
|
147
|
+
setBounds = vi.fn();
|
|
148
|
+
setBackgroundColor = vi.fn();
|
|
149
|
+
}
|
|
150
|
+
class View {
|
|
151
|
+
children = [];
|
|
152
|
+
addChildView(child) {
|
|
153
|
+
this.children.push(child);
|
|
154
|
+
}
|
|
155
|
+
removeChildView(child) {
|
|
156
|
+
const i = this.children.indexOf(child);
|
|
157
|
+
if (i >= 0)
|
|
158
|
+
this.children.splice(i, 1);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
class BrowserWindow extends EmittingStub {
|
|
162
|
+
destroyed = false;
|
|
163
|
+
webContents = new WebContents();
|
|
164
|
+
contentView = new WebContentsView();
|
|
165
|
+
isDestroyed = () => this.destroyed;
|
|
166
|
+
getContentSize = () => [1280, 980];
|
|
167
|
+
setIcon = vi.fn();
|
|
168
|
+
setTitle = vi.fn();
|
|
169
|
+
show = vi.fn();
|
|
170
|
+
showInactive = vi.fn();
|
|
171
|
+
focus = vi.fn();
|
|
172
|
+
close = vi.fn();
|
|
173
|
+
destroy = vi.fn(() => {
|
|
174
|
+
this.destroyed = true;
|
|
175
|
+
});
|
|
176
|
+
loadFile = vi.fn(() => Promise.resolve());
|
|
177
|
+
loadURL = vi.fn(() => Promise.resolve());
|
|
178
|
+
static getAllWindows = vi.fn(() => []);
|
|
179
|
+
}
|
|
180
|
+
const sessionStub = {
|
|
181
|
+
fromPartition: vi.fn(() => ({
|
|
182
|
+
webRequest: {
|
|
183
|
+
onBeforeSendHeaders: vi.fn(),
|
|
184
|
+
onHeadersReceived: vi.fn(),
|
|
185
|
+
},
|
|
186
|
+
registerPreloadScript: vi.fn(),
|
|
187
|
+
protocol: { handle: vi.fn(), unhandle: vi.fn() },
|
|
188
|
+
})),
|
|
189
|
+
defaultSession: {
|
|
190
|
+
protocol: { handle: vi.fn(), unhandle: vi.fn() },
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
const dialog = {
|
|
194
|
+
showOpenDialog: vi.fn(() => Promise.resolve({ canceled: true, filePaths: [] })),
|
|
195
|
+
showMessageBox: vi.fn(() => Promise.resolve({ response: 0 })),
|
|
196
|
+
};
|
|
197
|
+
const Menu = {
|
|
198
|
+
buildFromTemplate: vi.fn((tpl) => ({ template: tpl })),
|
|
199
|
+
setApplicationMenu: vi.fn(),
|
|
200
|
+
};
|
|
201
|
+
const shell = {
|
|
202
|
+
openExternal: vi.fn(() => Promise.resolve()),
|
|
203
|
+
openPath: vi.fn(() => Promise.resolve('')),
|
|
204
|
+
};
|
|
205
|
+
const nativeImage = {
|
|
206
|
+
createFromPath: vi.fn(() => ({ isEmpty: () => true })),
|
|
207
|
+
};
|
|
208
|
+
const nativeTheme = { ...stubs.makeEmitter(), themeSource: 'system' };
|
|
209
|
+
const globalShortcut = {
|
|
210
|
+
register: vi.fn(() => false),
|
|
211
|
+
unregister: vi.fn(),
|
|
212
|
+
unregisterAll: vi.fn(),
|
|
213
|
+
};
|
|
214
|
+
const webContentsStatic = {
|
|
215
|
+
fromId: vi.fn(() => null),
|
|
216
|
+
getAllWebContents: vi.fn(() => []),
|
|
217
|
+
};
|
|
218
|
+
const Tray = vi.fn();
|
|
219
|
+
return {
|
|
220
|
+
app,
|
|
221
|
+
ipcMain,
|
|
222
|
+
BrowserWindow,
|
|
223
|
+
WebContentsView,
|
|
224
|
+
BrowserView: WebContentsView,
|
|
225
|
+
View,
|
|
226
|
+
webContents: webContentsStatic,
|
|
227
|
+
session: sessionStub,
|
|
228
|
+
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn(), unhandle: vi.fn() },
|
|
229
|
+
dialog,
|
|
230
|
+
Menu,
|
|
231
|
+
shell,
|
|
232
|
+
nativeImage,
|
|
233
|
+
nativeTheme,
|
|
234
|
+
globalShortcut,
|
|
235
|
+
Tray,
|
|
236
|
+
default: {},
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
vi.mock('fs', async () => {
|
|
240
|
+
const real = await vi.importActual('fs');
|
|
241
|
+
function existsSync(p) {
|
|
242
|
+
const s = String(p);
|
|
243
|
+
if (s === stubs.projectsJsonPath)
|
|
244
|
+
return stubs.getProjectsJson() !== null;
|
|
245
|
+
if (s.endsWith('/app.json') || s.endsWith('\\app.json')) {
|
|
246
|
+
const dir = s.replace(/[\\/]app\.json$/, '');
|
|
247
|
+
return stubs.projectsWithAppJson.has(dir);
|
|
248
|
+
}
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
function readFileSync(p, opts) {
|
|
252
|
+
const s = String(p);
|
|
253
|
+
if (s === stubs.projectsJsonPath) {
|
|
254
|
+
const content = stubs.getProjectsJson();
|
|
255
|
+
if (content === null)
|
|
256
|
+
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
257
|
+
return content;
|
|
258
|
+
}
|
|
259
|
+
return real.readFileSync(p, opts);
|
|
260
|
+
}
|
|
261
|
+
function writeFileSync(p, data) {
|
|
262
|
+
const s = String(p);
|
|
263
|
+
if (s === stubs.projectsJsonPath) {
|
|
264
|
+
stubs.setProjectsJson(typeof data === 'string' ? data : Buffer.from(data).toString('utf8'));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const mocked = {
|
|
268
|
+
...real,
|
|
269
|
+
existsSync,
|
|
270
|
+
readFileSync,
|
|
271
|
+
writeFileSync,
|
|
272
|
+
mkdirSync: vi.fn(),
|
|
273
|
+
statSync: vi.fn(() => ({ isDirectory: () => true, isFile: () => false, size: 0, mtimeMs: 0 })),
|
|
274
|
+
watch: vi.fn(),
|
|
275
|
+
realpathSync: vi.fn((p) => p),
|
|
276
|
+
};
|
|
277
|
+
return { ...mocked, default: mocked };
|
|
278
|
+
});
|
|
279
|
+
const devkitStubs = vi.hoisted(() => ({
|
|
280
|
+
sessionClose: vi.fn(() => Promise.resolve()),
|
|
281
|
+
}));
|
|
282
|
+
vi.mock('@dimina-kit/devkit', () => ({
|
|
283
|
+
openProject: vi.fn(() => Promise.resolve({
|
|
284
|
+
port: 12345,
|
|
285
|
+
appInfo: { appId: 'fakeApp' },
|
|
286
|
+
close: devkitStubs.sessionClose,
|
|
287
|
+
})),
|
|
288
|
+
}));
|
|
289
|
+
// ── view-manager spy shim ───────────────────────────────────────────────
|
|
290
|
+
// `createDevtoolsBackend` keeps its assembled `instance` in a private
|
|
291
|
+
// closure — the returned `RuntimeBackend` exposes no getter for
|
|
292
|
+
// `instance.context.views`. Wrapping `createViewManager` here (real
|
|
293
|
+
// implementation, just observed) gives the test a handle on the exact
|
|
294
|
+
// `ViewManager` the backend's `assemble` wires up, equivalent to
|
|
295
|
+
// `vi.spyOn(instance.context.views, 'disposeAll')` without needing the
|
|
296
|
+
// backend to leak its internals.
|
|
297
|
+
const viewManagerStubs = vi.hoisted(() => ({
|
|
298
|
+
createdManagers: [],
|
|
299
|
+
}));
|
|
300
|
+
vi.mock('../services/views/view-manager.js', async (importOriginal) => {
|
|
301
|
+
const actual = await importOriginal();
|
|
302
|
+
return {
|
|
303
|
+
...actual,
|
|
304
|
+
createViewManager: (ctx) => {
|
|
305
|
+
const real = actual.createViewManager(ctx);
|
|
306
|
+
vi.spyOn(real, 'disposeAll');
|
|
307
|
+
viewManagerStubs.createdManagers.push(real);
|
|
308
|
+
return real;
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
export { stubs, devkitStubs, viewManagerStubs };
|
|
313
|
+
export function registerBackendTestLifecycle() {
|
|
314
|
+
const state = {};
|
|
315
|
+
beforeEach(async () => {
|
|
316
|
+
vi.resetModules();
|
|
317
|
+
stubs.reset();
|
|
318
|
+
devkitStubs.sessionClose.mockClear();
|
|
319
|
+
viewManagerStubs.createdManagers.length = 0;
|
|
320
|
+
state.electron = await import('electron');
|
|
321
|
+
({ createDevtoolsBackend: state.createDevtoolsBackend } = await import('./devtools-backend.js'));
|
|
322
|
+
});
|
|
323
|
+
return state;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Shared setup for the two tests (one in `devtools-backend-before-quit.test.ts`,
|
|
327
|
+
* one in `devtools-backend-shutdown-race.test.ts`) that need to observe
|
|
328
|
+
* `assemble()` PAUSED mid-flight, inside a still-pending `config.onSetup`:
|
|
329
|
+
* builds the backend with a gated `onSetup`, runs `beforeReady`/`assemble`,
|
|
330
|
+
* and waits until the assembled `ViewManager` is actually reachable (proof
|
|
331
|
+
* `createDevtoolsRuntime` has progressed past instance construction) before
|
|
332
|
+
* handing control back — the caller then fires whatever quit-path event it's
|
|
333
|
+
* testing while `onSetup` is still gated, and is responsible for calling
|
|
334
|
+
* `releaseOnSetup()` + awaiting `assemblePromise` itself.
|
|
335
|
+
*/
|
|
336
|
+
export async function startAssemblingWithGatedOnSetup(createDevtoolsBackend, extraConfig = {}) {
|
|
337
|
+
let releaseOnSetup = () => { };
|
|
338
|
+
const onSetupGate = new Promise((resolve) => {
|
|
339
|
+
releaseOnSetup = resolve;
|
|
340
|
+
});
|
|
341
|
+
const backend = createDevtoolsBackend({
|
|
342
|
+
...extraConfig,
|
|
343
|
+
onSetup: async () => {
|
|
344
|
+
await onSetupGate;
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
backend.beforeReady?.({});
|
|
348
|
+
const assemblePromise = Promise.resolve(backend.assemble({}));
|
|
349
|
+
// Let `assemble` progress through `createDevtoolsRuntime` up to (and into)
|
|
350
|
+
// the pending `onSetup` gate — the instance must exist by then.
|
|
351
|
+
await vi.waitFor(() => {
|
|
352
|
+
expect(viewManagerStubs.createdManagers.length).toBeGreaterThan(0);
|
|
353
|
+
});
|
|
354
|
+
return { backend, assemblePromise, releaseOnSetup };
|
|
355
|
+
}
|
|
356
|
+
//# sourceMappingURL=devtools-backend-before-quit.harness.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { runDevtoolsBootstrap, createDevtoolsRuntime } from '../app/app.js';
|
|
2
|
-
import { registerAppLifecycle } from '../app/lifecycle.js';
|
|
2
|
+
import { isAppQuitting, registerAppLifecycle } from '../app/lifecycle.js';
|
|
3
3
|
/**
|
|
4
4
|
* Adapts the devtools runtime into a {@link RuntimeBackend} the framework
|
|
5
5
|
* (`@dimina-kit/electron-deck`) orchestrates. The framework owns the process
|
|
@@ -21,6 +21,15 @@ import { registerAppLifecycle } from '../app/lifecycle.js';
|
|
|
21
21
|
export function createDevtoolsBackend(config = {}) {
|
|
22
22
|
// Hoisted so `onShutdown` can reach the assembled context (assigned by `assemble`).
|
|
23
23
|
let instance = null;
|
|
24
|
+
// `assemble`'s own in-flight promise: `instance` is now published EARLY (see
|
|
25
|
+
// below), before `createDevtoolsRuntime`'s async body — including the host's
|
|
26
|
+
// `config.onSetup(instance)` and everything after it — has finished running.
|
|
27
|
+
// `onShutdown` must wait for THIS to settle before disposing: disposing
|
|
28
|
+
// `instance.context.registry` while `createDevtoolsRuntime` is still adding
|
|
29
|
+
// entries to that same registry (e.g. the `updateChecker`/`setupMcp` wiring
|
|
30
|
+
// that runs after `onSetup`) hits `DisposableRegistry.add()`'s "cannot add
|
|
31
|
+
// to disposed registry" throw instead of a clean teardown.
|
|
32
|
+
let assembling = null;
|
|
24
33
|
return {
|
|
25
34
|
// The backend builds the devtools main window itself (framework skips its own).
|
|
26
35
|
// Its only construction-time needs are the host preload + `sandbox:false`
|
|
@@ -42,15 +51,51 @@ export function createDevtoolsBackend(config = {}) {
|
|
|
42
51
|
// Setup (post-whenReady): app lifecycle (window-all-closed → quit) + the
|
|
43
52
|
// full devtools assembly.
|
|
44
53
|
assemble: async () => {
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
// `onBeforeQuit` runs synchronously at Electron's `before-quit` — main
|
|
55
|
+
// loop still fully healthy, well before `will-quit`'s unawaited
|
|
56
|
+
// `shutdown()` and any window/WebContentsView destruction. Disposing
|
|
57
|
+
// the host-scoped views (host-toolbar's WebContentsView + its
|
|
58
|
+
// MessagePortMain) here, instead of leaving them to `onShutdown`'s
|
|
59
|
+
// best-effort async teardown, keeps them from surviving into
|
|
60
|
+
// Chromium's native shutdown sequence, where a late `'destroyed'`
|
|
61
|
+
// handler closing an already-torn-down MessagePort segfaults natively.
|
|
62
|
+
// Safe to run again from `onShutdown`'s `instance.dispose()` afterwards
|
|
63
|
+
// — `views.disposeAll()` and its constituents are dispose-idempotent.
|
|
64
|
+
registerAppLifecycle(() => instance?.context.views.disposeAll());
|
|
65
|
+
// `onInstanceCreated` (not the return value) is what assigns `instance`:
|
|
66
|
+
// `createDevtoolsRuntime` awaits the host's `config.onSetup(instance)`
|
|
67
|
+
// — which may run arbitrarily long and can itself load the host toolbar
|
|
68
|
+
// (a live MessagePort) — before returning. Waiting for the return value
|
|
69
|
+
// would leave `instance` null, and the before-quit hook above a no-op,
|
|
70
|
+
// for that entire window. Publishing from the callback closes it.
|
|
71
|
+
assembling = createDevtoolsRuntime(config, (created) => {
|
|
72
|
+
instance = created;
|
|
73
|
+
// Quit may have already started (before-quit already fired) before
|
|
74
|
+
// this instance existed, so the hook above ran with nothing to
|
|
75
|
+
// dispose. Self-heal: run the same teardown now that there is.
|
|
76
|
+
if (isAppQuitting())
|
|
77
|
+
instance.context.views.disposeAll();
|
|
78
|
+
}).then(() => { });
|
|
79
|
+
await assembling;
|
|
47
80
|
},
|
|
48
81
|
// Dispose the devtools context during the framework's deterministic shutdown
|
|
49
82
|
// (app.on('will-quit') → shutdown() → runShutdownCleanup(), awaited once).
|
|
50
83
|
// Without this, the compile session (a child process, torn down by
|
|
51
84
|
// closeProject) and the IPC registry would leak on exit. The framework awaits
|
|
52
85
|
// this hook, so teardown is no longer a best-effort before-quit reach-around.
|
|
53
|
-
|
|
86
|
+
//
|
|
87
|
+
// Waits for `assembling` first: `instance` can be non-null (published
|
|
88
|
+
// early, above) while `createDevtoolsRuntime`'s own async body — the
|
|
89
|
+
// host's `onSetup` and everything scheduled after it — is still running
|
|
90
|
+
// and still adding entries to `instance.context.registry`. Disposing
|
|
91
|
+
// that registry out from under an in-flight assembly throws instead of
|
|
92
|
+
// tearing down cleanly (`DisposableRegistry.add` rejects post-dispose).
|
|
93
|
+
// `.catch()` here: assembly failing is `assemble`'s problem to surface,
|
|
94
|
+
// not a reason to skip disposing whatever DID get constructed.
|
|
95
|
+
onShutdown: async () => {
|
|
96
|
+
await assembling?.catch(() => { });
|
|
97
|
+
await instance?.dispose();
|
|
98
|
+
},
|
|
54
99
|
};
|
|
55
100
|
}
|
|
56
101
|
//# sourceMappingURL=devtools-backend.js.map
|
|
@@ -39,7 +39,6 @@ export function startMcpServer(resolvedCdpPort, mcpPort) {
|
|
|
39
39
|
// Connect to both targets (non-blocking)
|
|
40
40
|
connectTarget('simulator').catch(() => { });
|
|
41
41
|
connectTarget('workbench').catch(() => { });
|
|
42
|
-
const server = buildServer();
|
|
43
42
|
const transports = new Map();
|
|
44
43
|
const httpServer = createServer(async (req, res) => {
|
|
45
44
|
const url = new URL(req.url ?? '/', `http://localhost`);
|
|
@@ -47,7 +46,30 @@ export function startMcpServer(resolvedCdpPort, mcpPort) {
|
|
|
47
46
|
const transport = new SSEServerTransport('/message', res);
|
|
48
47
|
transports.set(transport.sessionId, transport);
|
|
49
48
|
res.on('close', () => transports.delete(transport.sessionId));
|
|
50
|
-
|
|
49
|
+
try {
|
|
50
|
+
// A fresh McpServer PER CONNECTION: the SDK's Protocol is strictly
|
|
51
|
+
// 1:1 with its transport (`connect()` throws "Already connected to a
|
|
52
|
+
// transport" if `this._transport` is already set), so reusing one
|
|
53
|
+
// global server across concurrent SSE clients breaks every
|
|
54
|
+
// connection after the first. Registration only builds tool-call
|
|
55
|
+
// closures over already-shared module-level state (target-manager's
|
|
56
|
+
// CDP connections/listeners) — it has no per-call side effects, so
|
|
57
|
+
// building N servers is cheap and duplicates nothing.
|
|
58
|
+
await buildServer().connect(transport);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
transports.delete(transport.sessionId);
|
|
62
|
+
// `connect()` calls `transport.start()` (writes the SSE 200 + headers)
|
|
63
|
+
// BEFORE it can throw, so a post-start failure here has already sent
|
|
64
|
+
// headers — writeHead would itself throw. End the response instead so
|
|
65
|
+
// the client's stream closes cleanly rather than hanging forever with
|
|
66
|
+
// an open connection nothing will ever write to again.
|
|
67
|
+
if (!res.headersSent)
|
|
68
|
+
res.writeHead(500).end('MCP connect failed');
|
|
69
|
+
else
|
|
70
|
+
res.end();
|
|
71
|
+
console.error('[MCP] SSE connect failed:', err);
|
|
72
|
+
}
|
|
51
73
|
}
|
|
52
74
|
else if (req.method === 'POST' && url.pathname === '/message') {
|
|
53
75
|
const sessionId = url.searchParams.get('sessionId') ?? '';
|
|
@@ -63,9 +63,16 @@ async function materializeTemplate(target, template, name) {
|
|
|
63
63
|
if (!fs.existsSync(template.source.path)) {
|
|
64
64
|
throw new Error(`Template source missing on disk: ${template.source.path}`);
|
|
65
65
|
}
|
|
66
|
-
// `recursive: true` makes
|
|
67
|
-
//
|
|
68
|
-
fs.cpSync
|
|
66
|
+
// `recursive: true` makes cp mirror the entire tree (files, subdirs,
|
|
67
|
+
// symlinks). `force: true` mirrors over an empty target. `fs.promises.cp`
|
|
68
|
+
// (not the sync `fs.cpSync`) deliberately: cpSync's no-filter directory
|
|
69
|
+
// walk takes Node's native `cpSyncCopyDir` fast path, whose internal
|
|
70
|
+
// filesystem calls can `abort()` the whole process on certain errors
|
|
71
|
+
// (long/restricted/non-ASCII paths — nodejs/node#63970) instead of
|
|
72
|
+
// throwing a catchable JS exception. The async `cp` walks in pure JS and
|
|
73
|
+
// turns the same failures into a normal rejection this function already
|
|
74
|
+
// propagates to its IPC-boundary try/catch.
|
|
75
|
+
await fs.promises.cp(template.source.path, target, {
|
|
69
76
|
recursive: true,
|
|
70
77
|
force: true,
|
|
71
78
|
});
|
|
@@ -207,6 +207,12 @@ function httpGetText(url, init) {
|
|
|
207
207
|
function downloadFile(url, dest, token, onProgress) {
|
|
208
208
|
return new Promise((resolve, reject) => {
|
|
209
209
|
const headers = ghHeaders(token);
|
|
210
|
+
// Awaited before every reject so callers (and tests) that observe the
|
|
211
|
+
// rejection never race a partial file still being unlinked — fs.unlink's
|
|
212
|
+
// callback form settles asynchronously, after the promise had already
|
|
213
|
+
// rejected, which left a truncated/aborted download file on disk for a
|
|
214
|
+
// window after the caller believed cleanup was done.
|
|
215
|
+
const cleanup = () => fs.promises.unlink(dest).catch(() => { });
|
|
210
216
|
// GitHub asset downloads redirect to signed S3 URLs; keep following them.
|
|
211
217
|
const attempt = (target, redirectsLeft) => {
|
|
212
218
|
const req = https.get(target, { headers }, (res) => {
|
|
@@ -236,11 +242,24 @@ function downloadFile(url, dest, token, onProgress) {
|
|
|
236
242
|
onProgress((received / total) * 100);
|
|
237
243
|
}
|
|
238
244
|
});
|
|
245
|
+
// `.pipe()` does not forward source stream errors to the destination —
|
|
246
|
+
// an aborted/reset connection here would otherwise surface as an
|
|
247
|
+
// unhandled 'error' event on `res` (crashes the process) instead of
|
|
248
|
+
// rejecting this promise.
|
|
249
|
+
res.on('error', async (err) => { out.destroy(); await cleanup(); reject(err); });
|
|
239
250
|
res.pipe(out);
|
|
240
|
-
out.on('finish', () => {
|
|
241
|
-
|
|
251
|
+
out.on('finish', async () => {
|
|
252
|
+
out.close();
|
|
253
|
+
if (total > 0 && received !== total) {
|
|
254
|
+
await cleanup();
|
|
255
|
+
reject(new Error(`Downloaded ${received} bytes, expected ${total} — update download was truncated`));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
resolve();
|
|
259
|
+
});
|
|
260
|
+
out.on('error', async (err) => { await cleanup(); reject(err); });
|
|
242
261
|
});
|
|
243
|
-
req.on('error', (err) => {
|
|
262
|
+
req.on('error', async (err) => { await cleanup(); reject(err); });
|
|
244
263
|
};
|
|
245
264
|
attempt(url, 5);
|
|
246
265
|
});
|
|
@@ -48,7 +48,10 @@ export declare class UpdateManager {
|
|
|
48
48
|
filePath?: string;
|
|
49
49
|
error?: string;
|
|
50
50
|
}>;
|
|
51
|
-
install():
|
|
51
|
+
install(): Promise<{
|
|
52
|
+
success: boolean;
|
|
53
|
+
error?: string;
|
|
54
|
+
}>;
|
|
52
55
|
/**
|
|
53
56
|
* Tear down timers + IPC handlers. Returns a Promise so callers that need
|
|
54
57
|
* a settled cleanup (e.g. workbench registry shutdown, the unit-test
|
|
@@ -39,8 +39,21 @@ export class UpdateManager {
|
|
|
39
39
|
const currentVersion = this.getCurrentVersion();
|
|
40
40
|
const info = await this.checker.checkForUpdates(currentVersion);
|
|
41
41
|
if (info) {
|
|
42
|
+
// The periodic timer re-runs check() forever (see startPeriodicCheck),
|
|
43
|
+
// including after the user already downloaded this same update but
|
|
44
|
+
// before they clicked Install. Only invalidate downloadedPath when the
|
|
45
|
+
// update identity actually changed — unconditionally nulling it here
|
|
46
|
+
// would silently no-op a later install() (it's a bare `if (this.downloadedPath)`)
|
|
47
|
+
// for anyone who takes longer than one check interval to act. Compare
|
|
48
|
+
// downloadUrl too, not just version: a re-uploaded/replaced asset can
|
|
49
|
+
// keep the same version string under a different URL, and reusing the
|
|
50
|
+
// stale downloadedPath would then install bytes that were never
|
|
51
|
+
// actually verified against this checkForUpdates() result.
|
|
52
|
+
const isSameUpdate = this.latestUpdate?.version === info.version &&
|
|
53
|
+
this.latestUpdate?.downloadUrl === info.downloadUrl;
|
|
42
54
|
this.latestUpdate = info;
|
|
43
|
-
|
|
55
|
+
if (!isSameUpdate)
|
|
56
|
+
this.downloadedPath = null;
|
|
44
57
|
return { hasUpdate: true, info };
|
|
45
58
|
}
|
|
46
59
|
return { hasUpdate: false };
|
|
@@ -66,11 +79,23 @@ export class UpdateManager {
|
|
|
66
79
|
return { success: false, error: String(err) };
|
|
67
80
|
}
|
|
68
81
|
}
|
|
69
|
-
install() {
|
|
70
|
-
if (this.downloadedPath) {
|
|
71
|
-
|
|
72
|
-
app.quit();
|
|
82
|
+
async install() {
|
|
83
|
+
if (!this.downloadedPath) {
|
|
84
|
+
return { success: false, error: 'No update downloaded' };
|
|
73
85
|
}
|
|
86
|
+
// shell.openPath() resolves (never rejects) with an empty string on
|
|
87
|
+
// success or an error message on failure — it was previously fired
|
|
88
|
+
// fire-and-forget immediately followed by app.quit(), so a failure (e.g.
|
|
89
|
+
// no application associated with the installer) silently closed the app
|
|
90
|
+
// without ever launching the installer, and the renderer had no way to
|
|
91
|
+
// know anything went wrong.
|
|
92
|
+
const failure = await shell.openPath(this.downloadedPath);
|
|
93
|
+
if (failure) {
|
|
94
|
+
console.warn('[UpdateManager] install failed:', failure);
|
|
95
|
+
return { success: false, error: failure };
|
|
96
|
+
}
|
|
97
|
+
app.quit();
|
|
98
|
+
return { success: true };
|
|
74
99
|
}
|
|
75
100
|
/**
|
|
76
101
|
* Tear down timers + IPC handlers. Returns a Promise so callers that need
|