@specific.dev/spectest 0.86.2 → 0.88.0
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/browser.d.ts +7 -0
- package/dist/browser.js +102 -10
- package/dist/components/aws.d.ts +8 -7
- package/dist/components/aws.js +6 -0
- package/dist/components/electron-chrome.d.ts +1 -0
- package/dist/components/electron-chrome.js +76 -0
- package/dist/components/electron.d.ts +46 -0
- package/dist/components/electron.js +99 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/supabase-functions-reach.d.ts +18 -0
- package/dist/components/supabase-functions-reach.js +155 -0
- package/dist/components/supabase.js +33 -5
- package/dist/daemon.js +79 -17
- package/dist/desktop-recorder.d.ts +1 -0
- package/dist/desktop-recorder.js +30 -0
- package/dist/desktop.d.ts +42 -0
- package/dist/desktop.js +10 -0
- package/dist/harness/coverage.d.ts +9 -3
- package/dist/harness/coverage.js +21 -3
- package/dist/harness/reach.d.ts +33 -2
- package/dist/harness/reach.js +111 -9
- package/dist/harness/wrapper-rules.d.ts +13 -0
- package/dist/harness/wrapper-rules.js +37 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +5 -0
- package/dist/recorder.d.ts +2 -0
- package/package.json +1 -1
- package/src/browser.ts +106 -10
- package/src/components/aws.ts +6 -0
- package/src/components/electron-chrome.ts +76 -0
- package/src/components/electron.test.ts +38 -0
- package/src/components/electron.ts +115 -0
- package/src/components/index.ts +1 -0
- package/src/components/supabase-functions-reach.test.ts +90 -0
- package/src/components/supabase-functions-reach.ts +153 -0
- package/src/components/supabase.ts +33 -5
- package/src/daemon.ts +90 -21
- package/src/desktop-recorder.test.ts +55 -0
- package/src/desktop-recorder.ts +30 -0
- package/src/desktop.ts +43 -0
- package/src/harness/coverage.ts +20 -3
- package/src/harness/reach.test.ts +79 -1
- package/src/harness/reach.ts +118 -9
- package/src/harness/wrapper-rules.test.ts +26 -0
- package/src/harness/wrapper-rules.ts +39 -0
- package/src/index.ts +33 -0
- package/src/inputs.test.ts +20 -0
- package/src/recorder.ts +2 -0
package/dist/browser.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DesktopChrome } from "./desktop.js";
|
|
1
2
|
import type { Wrapped } from "./inspect.js";
|
|
2
3
|
import type { UrlPattern } from "./url-match.js";
|
|
3
4
|
export type { UrlPattern } from "./url-match.js";
|
|
@@ -68,6 +69,7 @@ export interface BrowserSessionRecorder {
|
|
|
68
69
|
* from the persisted events alone, with no replay bundle).
|
|
69
70
|
*/
|
|
70
71
|
readonly sessionName?: string;
|
|
72
|
+
readonly frame?: "browser" | "mobile" | "desktop";
|
|
71
73
|
/** Called for each drained chunk of rrweb events. */
|
|
72
74
|
recordStep(step: BrowserSessionStep): void;
|
|
73
75
|
/** Optional: called whenever `Browser.goto(url)` is invoked. */
|
|
@@ -342,6 +344,11 @@ export declare function acquirePersistentBrowser(name?: string, opts?: BrowserOp
|
|
|
342
344
|
* session navigates to the app; an attach continues on the live page.
|
|
343
345
|
*/
|
|
344
346
|
export declare function acquirePersistentMobileBackend(url: string, name: string, recorder: BrowserSessionRecorder | null, initScript?: string): Promise<PersistentBrowser>;
|
|
347
|
+
/** Attach the shared locator/recorder backend to an actual Electron window.
|
|
348
|
+
* Never navigate, reload, emulate a viewport, or replace its renderer. */
|
|
349
|
+
export declare function acquirePersistentDesktop(endpoint: string, windowIndex: number, recorder: BrowserSessionRecorder | null): Promise<PersistentBrowser & {
|
|
350
|
+
desktopChrome?: DesktopChrome;
|
|
351
|
+
}>;
|
|
345
352
|
export interface RecordableFields {
|
|
346
353
|
url: string;
|
|
347
354
|
selector: string;
|
package/dist/browser.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Headless browser handle for tests. Thin wrapper around playwright-core
|
|
2
2
|
// driving the guest's system Chromium (pipe transport — `chromium.launch`
|
|
3
|
-
// with an explicit `executablePath
|
|
4
|
-
//
|
|
5
|
-
// surface stable (we swapped from Bun.WebView to Playwright without
|
|
3
|
+
// with an explicit `executablePath`). Electron attaches to the app-owned
|
|
4
|
+
// Chromium over CDP instead; its client also lives inside the VM. The wrapper
|
|
5
|
+
// keeps the public surface stable (we swapped from Bun.WebView to Playwright without
|
|
6
6
|
// changing tests) and routes every call through the recorder so browser
|
|
7
7
|
// actions show up in a test's event log alongside exec/fetch/assertion
|
|
8
8
|
// events. Playwright's client state (browser/context/page objects, the
|
|
@@ -32,6 +32,8 @@ import { readFileSync } from "node:fs";
|
|
|
32
32
|
import path from "node:path";
|
|
33
33
|
import { fileURLToPath } from "node:url";
|
|
34
34
|
import { generateId } from "./ids.js";
|
|
35
|
+
import { rawFetch } from "./harness/raw-fetch.js";
|
|
36
|
+
import { DESKTOP_INPUT_SYNC_SCRIPT } from "./desktop-recorder.js";
|
|
35
37
|
import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
36
38
|
import { wrap } from "./inspect.js";
|
|
37
39
|
import { capturePageStructure } from "./page-snapshot.js";
|
|
@@ -760,7 +762,7 @@ const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
|
|
|
760
762
|
// snapshot keeps steady-state steps (clicks/types on an unchanged page,
|
|
761
763
|
// or navigations where `load` already fired) from bloating the stream
|
|
762
764
|
// with redundant snapshots.
|
|
763
|
-
function drainExpr(forceFullIfMissing) {
|
|
765
|
+
function drainExpr(forceFullIfMissing, timestamp = Date.now()) {
|
|
764
766
|
return `(function () {
|
|
765
767
|
try {
|
|
766
768
|
var rec = window.__spectestRec;
|
|
@@ -775,6 +777,9 @@ function drainExpr(forceFullIfMissing) {
|
|
|
775
777
|
if (!hasFull) rec.takeFullSnapshot();
|
|
776
778
|
}
|
|
777
779
|
} catch (e) { /* recording inactive or DOM detached — drain what's there. */ }
|
|
780
|
+
if (typeof window.__spectestSyncInputs === "function") {
|
|
781
|
+
window.__spectestSyncInputs(${timestamp});
|
|
782
|
+
}
|
|
778
783
|
var buf = window.__spectestRrwebEvents;
|
|
779
784
|
if (!buf || !buf.length) return [];
|
|
780
785
|
window.__spectestRrwebEvents = [];
|
|
@@ -1070,6 +1075,91 @@ export async function acquirePersistentMobileBackend(url, name, recorder, initSc
|
|
|
1070
1075
|
}
|
|
1071
1076
|
return { browser: backend, attached: existing !== undefined, detach };
|
|
1072
1077
|
}
|
|
1078
|
+
/** Live CDP clients and window identities ride the harness snapshot. */
|
|
1079
|
+
const ELECTRON_APPS = new Map();
|
|
1080
|
+
/** Attach the shared locator/recorder backend to an actual Electron window.
|
|
1081
|
+
* Never navigate, reload, emulate a viewport, or replace its renderer. */
|
|
1082
|
+
export async function acquirePersistentDesktop(endpoint, windowIndex, recorder) {
|
|
1083
|
+
if (!Number.isInteger(windowIndex) || windowIndex < 0) {
|
|
1084
|
+
throw new Error("ctx.desktop(): window must be a non-negative integer (creation order)");
|
|
1085
|
+
}
|
|
1086
|
+
let pending = ELECTRON_APPS.get(endpoint);
|
|
1087
|
+
if (!pending) {
|
|
1088
|
+
pending = (async () => {
|
|
1089
|
+
const response = await rawFetch(new URL("/json/version", endpoint), {
|
|
1090
|
+
signal: AbortSignal.timeout(15_000),
|
|
1091
|
+
});
|
|
1092
|
+
if (!response.ok)
|
|
1093
|
+
throw new Error(`Electron debugger discovery failed: HTTP ${response.status}`);
|
|
1094
|
+
const info = await response.json();
|
|
1095
|
+
const websocket = new URL(info.webSocketDebuggerUrl);
|
|
1096
|
+
websocket.host = new URL(endpoint).host;
|
|
1097
|
+
const browser = await chromium.connectOverCDP(websocket.toString(), { timeout: 15_000 });
|
|
1098
|
+
const context = browser.contexts()[0];
|
|
1099
|
+
if (!context) {
|
|
1100
|
+
await browser.close();
|
|
1101
|
+
throw new Error("Electron has no renderer context");
|
|
1102
|
+
}
|
|
1103
|
+
const windows = context.pages().filter(page => !page.url().startsWith("devtools:"));
|
|
1104
|
+
context.on("page", page => { if (!page.url().startsWith("devtools:"))
|
|
1105
|
+
windows.push(page); });
|
|
1106
|
+
browser.on("disconnected", () => { ELECTRON_APPS.delete(endpoint); });
|
|
1107
|
+
return { browser, context, windows, holders: new Map() };
|
|
1108
|
+
})();
|
|
1109
|
+
ELECTRON_APPS.set(endpoint, pending);
|
|
1110
|
+
pending.catch(() => { if (ELECTRON_APPS.get(endpoint) === pending)
|
|
1111
|
+
ELECTRON_APPS.delete(endpoint); });
|
|
1112
|
+
}
|
|
1113
|
+
const app = await pending;
|
|
1114
|
+
const deadline = Date.now() + 15_000;
|
|
1115
|
+
while (!app.windows[windowIndex]) {
|
|
1116
|
+
const remaining = deadline - Date.now();
|
|
1117
|
+
if (remaining <= 0)
|
|
1118
|
+
throw new Error(`Electron window ${windowIndex} did not open`);
|
|
1119
|
+
await app.context.waitForEvent("page", { timeout: remaining });
|
|
1120
|
+
}
|
|
1121
|
+
const page = app.windows[windowIndex];
|
|
1122
|
+
if (page.isClosed())
|
|
1123
|
+
throw new Error(`Electron window ${windowIndex} is closed; window indices retain creation order`);
|
|
1124
|
+
await page.waitForLoadState("domcontentloaded");
|
|
1125
|
+
// The main-process observer publishes only window presentation options.
|
|
1126
|
+
// A popup created internally by Chromium might not pass through BrowserWindow.
|
|
1127
|
+
await page.waitForFunction(() => Boolean(window.__spectestElectronChrome), null, { timeout: 3000 }).catch(() => { });
|
|
1128
|
+
const desktopChrome = await page.evaluate(() => {
|
|
1129
|
+
const chrome = window.__spectestElectronChrome;
|
|
1130
|
+
if (!chrome)
|
|
1131
|
+
return undefined;
|
|
1132
|
+
const overlay = navigator.windowControlsOverlay;
|
|
1133
|
+
return { ...chrome, ...(overlay?.visible ? { titlebarArea: overlay.getTitlebarAreaRect().toJSON() } : {}) };
|
|
1134
|
+
});
|
|
1135
|
+
let holder = app.holders.get(page);
|
|
1136
|
+
const attached = holder !== undefined;
|
|
1137
|
+
if (!holder) {
|
|
1138
|
+
const cdp = await app.context.newCDPSession(page);
|
|
1139
|
+
await cdp.send("Page.enable");
|
|
1140
|
+
if (!PAGE_INIT_SCRIPT)
|
|
1141
|
+
throw new Error("Electron replay recorder bundle is missing");
|
|
1142
|
+
const init = `${PAGE_INIT_SCRIPT};\n${DESKTOP_INPUT_SYNC_SCRIPT}`;
|
|
1143
|
+
await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: init });
|
|
1144
|
+
// The app owns navigation and may already have loaded. Start on its live
|
|
1145
|
+
// DOM, without losing drafts or replaying any application startup effects.
|
|
1146
|
+
await page.evaluate(init);
|
|
1147
|
+
const size = await page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
|
|
1148
|
+
holder = {
|
|
1149
|
+
context: app.context, page, cdp, lastTitle: await page.title(),
|
|
1150
|
+
recordingInstalled: true, device: null, width: size.width, height: size.height,
|
|
1151
|
+
safeAreaInsets: null, initScripts: [],
|
|
1152
|
+
};
|
|
1153
|
+
app.holders.set(page, holder);
|
|
1154
|
+
page.on("close", () => { app.holders.delete(page); });
|
|
1155
|
+
}
|
|
1156
|
+
await attachReset(holder);
|
|
1157
|
+
const { backend, detach } = buildBackend(holder, recorder, {
|
|
1158
|
+
persistent: false, ownsContext: false,
|
|
1159
|
+
onDestroy: () => { app.holders.delete(page); },
|
|
1160
|
+
});
|
|
1161
|
+
return { browser: backend, attached, detach, desktopChrome };
|
|
1162
|
+
}
|
|
1073
1163
|
/** True when a navigation failed on Chromium name resolution. */
|
|
1074
1164
|
function isNameNotResolved(err) {
|
|
1075
1165
|
return String(err?.message ?? err).includes("ERR_NAME_NOT_RESOLVED");
|
|
@@ -1183,15 +1273,16 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1183
1273
|
return {
|
|
1184
1274
|
sessionId: recorder.sessionId,
|
|
1185
1275
|
sessionTimestamp: endT,
|
|
1276
|
+
...(recorder.frame ? { frame: recorder.frame } : {}),
|
|
1186
1277
|
...(recorder.sessionName ? { sessionName: recorder.sessionName } : {}),
|
|
1187
1278
|
};
|
|
1188
1279
|
}
|
|
1189
|
-
async function drain(action) {
|
|
1280
|
+
async function drain(action, timestamp = Date.now()) {
|
|
1190
1281
|
if (!holder.recordingInstalled || !recorder || recordingEnded)
|
|
1191
1282
|
return;
|
|
1192
1283
|
try {
|
|
1193
1284
|
const urlChanged = holder.page.url() !== lastDrainUrl;
|
|
1194
|
-
const events = (await holder.page.evaluate(drainExpr(urlChanged)));
|
|
1285
|
+
const events = (await holder.page.evaluate(drainExpr(urlChanged, timestamp)));
|
|
1195
1286
|
lastDrainUrl = holder.page.url();
|
|
1196
1287
|
if (Array.isArray(events) && events.length > 0) {
|
|
1197
1288
|
recorder.recordStep({
|
|
@@ -1232,7 +1323,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1232
1323
|
...sessionFields(endT),
|
|
1233
1324
|
durationMs: endT - t,
|
|
1234
1325
|
}, resv);
|
|
1235
|
-
await drain(action);
|
|
1326
|
+
await drain(action, endT);
|
|
1236
1327
|
// Fold this page's V8 counters into the per-service totals now — a
|
|
1237
1328
|
// navigation to another origin replaces the renderer and its
|
|
1238
1329
|
// counters, so harvesting per op is what keeps them.
|
|
@@ -1269,7 +1360,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1269
1360
|
}, resv);
|
|
1270
1361
|
// Still try to drain — the failure itself may have produced
|
|
1271
1362
|
// useful rrweb events (mutations from a half-loaded page, etc.).
|
|
1272
|
-
await drain(action);
|
|
1363
|
+
await drain(action, endT);
|
|
1273
1364
|
throw err;
|
|
1274
1365
|
}
|
|
1275
1366
|
}
|
|
@@ -1525,7 +1616,8 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1525
1616
|
// The context, not the page, holds the cookies and storage — closing
|
|
1526
1617
|
// only the page would leave the next session signed in as this one.
|
|
1527
1618
|
try {
|
|
1528
|
-
|
|
1619
|
+
if (buildOpts.ownsContext !== false)
|
|
1620
|
+
await holder.context.close();
|
|
1529
1621
|
}
|
|
1530
1622
|
catch {
|
|
1531
1623
|
/* context already gone (browser died) */
|
|
@@ -1606,7 +1698,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1606
1698
|
}, reserveBackdated(waitedMs));
|
|
1607
1699
|
// Drain the rrweb the page buffered while the matcher waited into this
|
|
1608
1700
|
// step's chunk, so `settledTarget` has bounds to seek into.
|
|
1609
|
-
await drain(action);
|
|
1701
|
+
await drain(action, endT);
|
|
1610
1702
|
return seq;
|
|
1611
1703
|
},
|
|
1612
1704
|
pageOp(action, fields, fn, opts) {
|
package/dist/components/aws.d.ts
CHANGED
|
@@ -95,6 +95,14 @@ export interface LambdaOptions {
|
|
|
95
95
|
*/
|
|
96
96
|
export declare function aws(opts?: AwsOptions): {
|
|
97
97
|
setup?: (({ name, projectRoot }: ServiceSetupContext) => Promise<void>) | undefined;
|
|
98
|
+
ports: number[];
|
|
99
|
+
readyCheck: {
|
|
100
|
+
type: "http";
|
|
101
|
+
port: number;
|
|
102
|
+
path: string;
|
|
103
|
+
timeoutSecs: number;
|
|
104
|
+
};
|
|
105
|
+
inputs?: string[] | undefined;
|
|
98
106
|
image: {
|
|
99
107
|
type: "registry";
|
|
100
108
|
reference: string;
|
|
@@ -109,11 +117,4 @@ export declare function aws(opts?: AwsOptions): {
|
|
|
109
117
|
source: string;
|
|
110
118
|
target: string;
|
|
111
119
|
}[];
|
|
112
|
-
ports: number[];
|
|
113
|
-
readyCheck: {
|
|
114
|
-
type: "http";
|
|
115
|
-
port: number;
|
|
116
|
-
path: string;
|
|
117
|
-
timeoutSecs: number;
|
|
118
|
-
};
|
|
119
120
|
};
|
package/dist/components/aws.js
CHANGED
|
@@ -191,6 +191,12 @@ export function aws(opts = {}) {
|
|
|
191
191
|
LAMBDA_DOCKER_NETWORK: SPECTEST_NETWORK,
|
|
192
192
|
},
|
|
193
193
|
volumes: [{ source: DOCKER_SOCKET, target: DOCKER_SOCKET }],
|
|
194
|
+
// Each lambda's code is packaged from the project at setup: no build
|
|
195
|
+
// context or volume carries it, so it is declared as an input and a
|
|
196
|
+
// change to it is never taken as one that can affect no test.
|
|
197
|
+
...(Object.keys(lambdas).length > 0
|
|
198
|
+
? { inputs: Object.values(lambdas).map((l) => l.source).filter((s) => typeof s === "string" && s.trim() !== "") }
|
|
199
|
+
: {}),
|
|
194
200
|
ports: [AWS_PORT],
|
|
195
201
|
readyCheck: {
|
|
196
202
|
type: "http",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const ELECTRON_CHROME_HOOK: string;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Runs before the application's main entry, like Playwright's Electron loader.
|
|
2
|
+
// Observe constructor options; do not replace the app entry, preload, IPC, or OS.
|
|
3
|
+
export const ELECTRON_CHROME_HOOK = String.raw `
|
|
4
|
+
const electron = require('electron');
|
|
5
|
+
const NativeBrowserWindow = electron.BrowserWindow;
|
|
6
|
+
const WrappedBrowserWindow = new Proxy(NativeBrowserWindow, {
|
|
7
|
+
construct(Target, args, NewTarget) {
|
|
8
|
+
const win = Reflect.construct(Target, args, NewTarget);
|
|
9
|
+
const options = args[0] || {};
|
|
10
|
+
const chrome = {
|
|
11
|
+
titleBarStyle: options.titleBarStyle || 'default',
|
|
12
|
+
frame: options.frame !== false,
|
|
13
|
+
trafficLightPosition: options.trafficLightPosition || null,
|
|
14
|
+
titleBarOverlay: options.titleBarOverlay || false,
|
|
15
|
+
roundedCorners: options.roundedCorners !== false,
|
|
16
|
+
buttonVisibility: null,
|
|
17
|
+
};
|
|
18
|
+
const publish = () => {
|
|
19
|
+
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
|
|
20
|
+
void win.webContents.executeJavaScript(
|
|
21
|
+
'window.__spectestElectronChrome = ' + JSON.stringify(chrome)
|
|
22
|
+
).catch(() => {}); // A document can be replaced while this is in flight.
|
|
23
|
+
};
|
|
24
|
+
win.webContents.on('dom-ready', publish);
|
|
25
|
+
// Capture supported native setters too, without adding macOS APIs on Linux.
|
|
26
|
+
for (const [method, key] of [
|
|
27
|
+
['setWindowButtonVisibility', 'buttonVisibility'],
|
|
28
|
+
['setWindowButtonPosition', 'trafficLightPosition'],
|
|
29
|
+
['setTitleBarOverlay', 'titleBarOverlay'],
|
|
30
|
+
]) {
|
|
31
|
+
if (typeof win[method] !== 'function') continue;
|
|
32
|
+
const original = win[method];
|
|
33
|
+
win[method] = function(value) {
|
|
34
|
+
const result = original.call(this, value);
|
|
35
|
+
chrome[key] = value;
|
|
36
|
+
publish();
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return win;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
// Electron's own exports are non-configurable. Wrap module resolution instead
|
|
44
|
+
// of modifying them; all other exports retain their original descriptors.
|
|
45
|
+
const Module = require('node:module');
|
|
46
|
+
const originalLoad = Module._load;
|
|
47
|
+
const facades = new Map();
|
|
48
|
+
Module._load = function(request, ...args) {
|
|
49
|
+
const exports = originalLoad.call(this, request, ...args);
|
|
50
|
+
if (request !== 'electron' && request !== 'electron/main') return exports;
|
|
51
|
+
if (!facades.has(exports)) {
|
|
52
|
+
const descriptors = Object.getOwnPropertyDescriptors(exports);
|
|
53
|
+
descriptors.BrowserWindow = { value: WrappedBrowserWindow, enumerable: true };
|
|
54
|
+
facades.set(exports, Object.defineProperties({}, descriptors));
|
|
55
|
+
}
|
|
56
|
+
return facades.get(exports);
|
|
57
|
+
};
|
|
58
|
+
// Electron's native ESM translator bypasses Module._load. Route ESM imports
|
|
59
|
+
// through the same facade using Node's synchronous loader hooks when available.
|
|
60
|
+
if (typeof Module.registerHooks === 'function') {
|
|
61
|
+
const source = 'import { createRequire } from "node:module";\n' +
|
|
62
|
+
'const electron = createRequire(' + JSON.stringify(__filename) + ')("electron");\n' +
|
|
63
|
+
'export default electron;\n' + Object.keys(electron)
|
|
64
|
+
.filter(key => /^[A-Za-z_$][\w$]*$/.test(key))
|
|
65
|
+
.map(key => 'export const ' + key + ' = electron.' + key + ';').join('\n');
|
|
66
|
+
const url = 'data:text/javascript,' + encodeURIComponent(source);
|
|
67
|
+
Module.registerHooks({
|
|
68
|
+
resolve(specifier, context, next) {
|
|
69
|
+
if ((specifier === 'electron' || specifier === 'electron/main') && context.conditions.includes('import')) {
|
|
70
|
+
return { url, shortCircuit: true };
|
|
71
|
+
}
|
|
72
|
+
return next(specifier, context);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type DesktopApp } from "../desktop.js";
|
|
2
|
+
export interface ElectronOptions {
|
|
3
|
+
/** Project-relative app directory; must contain package.json and Electron. */
|
|
4
|
+
appDir?: string;
|
|
5
|
+
/** Debian Node image tag, default 24-bookworm-slim. */
|
|
6
|
+
nodeVersion?: string;
|
|
7
|
+
/** Build the main/preload/renderer output; default npm run build. */
|
|
8
|
+
buildCommand?: string;
|
|
9
|
+
/** Dependency install command; defaults to npm ci when a lockfile exists. */
|
|
10
|
+
installCommand?: string;
|
|
11
|
+
/** App entry relative to /app, default . (package.json main). */
|
|
12
|
+
entry?: string;
|
|
13
|
+
env?: Record<string, string>;
|
|
14
|
+
dependsOn?: string[];
|
|
15
|
+
}
|
|
16
|
+
export declare const ELECTRON_LAUNCHER: string;
|
|
17
|
+
/** Real Linux Electron under Xvfb, with a generated image and graphical replay.
|
|
18
|
+
* Like expo(), returns an app handle for the matching context method.
|
|
19
|
+
* Requires Linux-compatible application dependencies and binaries. */
|
|
20
|
+
export declare function electron(options?: ElectronOptions): {
|
|
21
|
+
image: {
|
|
22
|
+
type: "dockerfile";
|
|
23
|
+
context: string;
|
|
24
|
+
exclude: string[];
|
|
25
|
+
content: string;
|
|
26
|
+
};
|
|
27
|
+
files: {
|
|
28
|
+
path: string;
|
|
29
|
+
content: string;
|
|
30
|
+
}[];
|
|
31
|
+
command: string;
|
|
32
|
+
env: {
|
|
33
|
+
SPECTEST_ELECTRON_ENTRY: string;
|
|
34
|
+
};
|
|
35
|
+
dependsOn: string[] | undefined;
|
|
36
|
+
ports: number[];
|
|
37
|
+
readyCheck: {
|
|
38
|
+
type: "http";
|
|
39
|
+
port: number;
|
|
40
|
+
path: string;
|
|
41
|
+
timeoutSecs: number;
|
|
42
|
+
};
|
|
43
|
+
helpers: ({ name }: {
|
|
44
|
+
name: string;
|
|
45
|
+
}) => DesktopApp;
|
|
46
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolveProjectPath } from "../project-files.js";
|
|
3
|
+
import { desktopApp } from "../desktop.js";
|
|
4
|
+
import { ELECTRON_CHROME_HOOK } from "./electron-chrome.js";
|
|
5
|
+
// Test-only debugger proxy: Electron binds its CDP socket to loopback. Forward
|
|
6
|
+
// HTTP and WebSocket traffic from the VM to that socket, normalizing Host as
|
|
7
|
+
// Chromium requires. It exposes no app-specific actions or state endpoints.
|
|
8
|
+
export const ELECTRON_LAUNCHER = String.raw `
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
const require = createRequire('/app/package.json');
|
|
13
|
+
const child = spawn(require('electron'), [
|
|
14
|
+
...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
|
|
15
|
+
'--disable-dev-shm-usage', '--remote-debugging-port=9222',
|
|
16
|
+
'-r', '/spectest-electron-chrome.cjs',
|
|
17
|
+
process.env.SPECTEST_ELECTRON_ENTRY || '.',
|
|
18
|
+
], { cwd: '/app', stdio: 'inherit', env: process.env });
|
|
19
|
+
const upstream = req => ({
|
|
20
|
+
host: '127.0.0.1', port: 9222, path: req.url, method: req.method,
|
|
21
|
+
headers: { ...req.headers, host: '127.0.0.1:9222' },
|
|
22
|
+
});
|
|
23
|
+
const server = http.createServer((req, res) => {
|
|
24
|
+
const proxy = http.request(upstream(req), response => {
|
|
25
|
+
res.writeHead(response.statusCode, response.headers);
|
|
26
|
+
response.pipe(res);
|
|
27
|
+
});
|
|
28
|
+
proxy.on('error', () => { res.writeHead(503); res.end('Electron debugger starting'); });
|
|
29
|
+
req.pipe(proxy);
|
|
30
|
+
});
|
|
31
|
+
server.on('upgrade', (req, socket, head) => {
|
|
32
|
+
const proxy = http.request(upstream(req));
|
|
33
|
+
proxy.on('upgrade', (response, remote, remoteHead) => {
|
|
34
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\n' +
|
|
35
|
+
Object.entries(response.headers).map(([k, v]) => k + ': ' + v).join('\r\n') + '\r\n\r\n');
|
|
36
|
+
if (remoteHead.length) socket.write(remoteHead);
|
|
37
|
+
if (head.length) remote.write(head);
|
|
38
|
+
socket.pipe(remote); remote.pipe(socket);
|
|
39
|
+
socket.on('error', () => remote.destroy());
|
|
40
|
+
remote.on('error', () => socket.destroy());
|
|
41
|
+
socket.on('close', () => remote.destroy());
|
|
42
|
+
remote.on('close', () => socket.destroy());
|
|
43
|
+
});
|
|
44
|
+
proxy.on('response', () => socket.destroy());
|
|
45
|
+
proxy.on('error', () => socket.destroy());
|
|
46
|
+
proxy.end();
|
|
47
|
+
});
|
|
48
|
+
server.listen(9223, '0.0.0.0');
|
|
49
|
+
child.on('error', error => { console.error(error); process.exit(1); });
|
|
50
|
+
child.on('exit', code => process.exit(code ?? 1));
|
|
51
|
+
for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => child.kill(signal));
|
|
52
|
+
`;
|
|
53
|
+
/** Real Linux Electron under Xvfb, with a generated image and graphical replay.
|
|
54
|
+
* Like expo(), returns an app handle for the matching context method.
|
|
55
|
+
* Requires Linux-compatible application dependencies and binaries. */
|
|
56
|
+
export function electron(options = {}) {
|
|
57
|
+
const appDir = options.appDir ?? ".";
|
|
58
|
+
const manifest = resolveProjectPath(`${appDir}/package.json`);
|
|
59
|
+
if (!existsSync(manifest))
|
|
60
|
+
throw new Error(`electron(): no package.json in ${appDir}`);
|
|
61
|
+
const pkg = JSON.parse(readFileSync(manifest, "utf8"));
|
|
62
|
+
if (!pkg.dependencies?.electron && !pkg.devDependencies?.electron) {
|
|
63
|
+
throw new Error("electron(): declare electron in the app's dependencies or devDependencies");
|
|
64
|
+
}
|
|
65
|
+
const locked = existsSync(resolveProjectPath(`${appDir}/package-lock.json`));
|
|
66
|
+
const node = options.nodeVersion ?? "24-bookworm-slim";
|
|
67
|
+
if (!/^[\w.-]+$/.test(node))
|
|
68
|
+
throw new Error("electron(): invalid Node image tag");
|
|
69
|
+
const install = options.installCommand ?? (locked ? "npm ci" : "npm install");
|
|
70
|
+
const build = options.buildCommand ?? "npm run build";
|
|
71
|
+
if ([install, build].some(command => /[\r\n]/.test(command))) {
|
|
72
|
+
throw new Error("electron(): build/install commands must be single-line shell commands");
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
image: {
|
|
76
|
+
type: "dockerfile",
|
|
77
|
+
context: appDir,
|
|
78
|
+
exclude: ["node_modules", "**/node_modules", "out", "test-results", ".data"],
|
|
79
|
+
content: `FROM node:${node}
|
|
80
|
+
RUN apt-get update && apt-get install -y --no-install-recommends xvfb xauth libgtk-3-0 libnss3 libgbm1 libasound2 fonts-liberation ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
81
|
+
WORKDIR /app
|
|
82
|
+
COPY . .
|
|
83
|
+
RUN ${install}
|
|
84
|
+
RUN node node_modules/electron/install.js
|
|
85
|
+
RUN ${build}
|
|
86
|
+
`,
|
|
87
|
+
},
|
|
88
|
+
files: [
|
|
89
|
+
{ path: "/spectest-electron.mjs", content: ELECTRON_LAUNCHER },
|
|
90
|
+
{ path: "/spectest-electron-chrome.cjs", content: ELECTRON_CHROME_HOOK },
|
|
91
|
+
],
|
|
92
|
+
command: "xvfb-run -a node /spectest-electron.mjs",
|
|
93
|
+
env: { ...options.env, SPECTEST_ELECTRON_ENTRY: options.entry ?? "." },
|
|
94
|
+
dependsOn: options.dependsOn,
|
|
95
|
+
ports: [9223],
|
|
96
|
+
readyCheck: { type: "http", port: 9223, path: "/json/version", timeoutSecs: 60 },
|
|
97
|
+
helpers: ({ name }) => desktopApp(`http://${name}.internal:9223`, name),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -14,3 +14,4 @@ export { apple, type AppleOptions } from "./apple.js";
|
|
|
14
14
|
export { microsoft, type MicrosoftOptions } from "./microsoft.js";
|
|
15
15
|
export { okta, type OktaOptions } from "./okta.js";
|
|
16
16
|
export { replayFake, type ReplayFakeOptions, type ReplayMatch, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
|
|
17
|
+
export { electron, type ElectronOptions } from "./electron.js";
|
package/dist/components/index.js
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Specifiers a source text imports, as written. */
|
|
2
|
+
export declare function importSpecifiers(text: string): string[];
|
|
3
|
+
/** Local targets an import map (`deno.json` `imports`, `import_map.json`)
|
|
4
|
+
* names, as written: relative values, and root-relative `/…` ones. */
|
|
5
|
+
export declare function importMapLocalTargets(text: string): string[];
|
|
6
|
+
/**
|
|
7
|
+
* The directories, project-relative and sorted, that the functions under
|
|
8
|
+
* `fnRelPath` can load modules from: `fnRelPath` itself, and every
|
|
9
|
+
* directory a relative import or a local import-map entry leads to,
|
|
10
|
+
* transitively. `["."]` when the walk runs out of budget. A directory
|
|
11
|
+
* nested under another in the answer is dropped.
|
|
12
|
+
*/
|
|
13
|
+
export declare function functionsReach(root: string, fnRelPath: string): string[];
|
|
14
|
+
/** The `exclude` list that narrows a root build context to `dirs`: drop
|
|
15
|
+
* everything, then re-include each directory and its subtree, then drop
|
|
16
|
+
* again what no context should carry (a re-included `packages/shared`
|
|
17
|
+
* would otherwise bring its `node_modules` along). */
|
|
18
|
+
export declare function excludeAllBut(dirs: readonly string[]): string[];
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// What the edge functions can load from the project — the pure part.
|
|
2
|
+
//
|
|
3
|
+
// The edge runtime serves each function from the project root, mounted
|
|
4
|
+
// whole so that a monorepo function's `../../packages/shared/mod.ts`
|
|
5
|
+
// resolves where it sits. Whole is right for the runtime and wrong for
|
|
6
|
+
// the reach: with the root in a build context and a volume, every file of
|
|
7
|
+
// the project could "reach" the functions, and a change to `docs/` could
|
|
8
|
+
// never skip a run. So the component works out the **module graph** the
|
|
9
|
+
// functions can load, statically, and hands the build context and the
|
|
10
|
+
// reach exactly that: the functions directory, and every directory a
|
|
11
|
+
// relative import or a local import-map entry leads to, followed
|
|
12
|
+
// transitively. Directories rather than files, so a sibling module a
|
|
13
|
+
// dynamic `import()` picks at run time comes along.
|
|
14
|
+
//
|
|
15
|
+
// What it cannot see: a computed specifier that leaves the directories
|
|
16
|
+
// found (`import(\`../../${x}/mod.ts\`)`), and a `Deno.readTextFile` of a
|
|
17
|
+
// path outside them. Both are rare in a function, and both are the same
|
|
18
|
+
// class of hole as a test reading the workspace through `node:fs`. A tree
|
|
19
|
+
// too large to walk within the budget is answered with the whole project,
|
|
20
|
+
// the safe side.
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
22
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
23
|
+
/** Files the walk parses for imports. */
|
|
24
|
+
const SOURCE_EXT = /\.(?:[mc]?[jt]sx?)$/;
|
|
25
|
+
/** Names never walked into. */
|
|
26
|
+
const SKIP = new Set(["node_modules", ".git", ".spectest", "dist", "build", ".next"]);
|
|
27
|
+
/** The walk gives up past this many files and answers with the root. */
|
|
28
|
+
const FILE_BUDGET = 20_000;
|
|
29
|
+
const IMPORT_RE = /\b(?:import|export)\b[^;'"`]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]|\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
30
|
+
/** Specifiers a source text imports, as written. */
|
|
31
|
+
export function importSpecifiers(text) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const m of text.matchAll(IMPORT_RE))
|
|
34
|
+
out.push(m[1] ?? m[2] ?? m[3] ?? "");
|
|
35
|
+
return out.filter((s) => s.length > 0);
|
|
36
|
+
}
|
|
37
|
+
/** Local targets an import map (`deno.json` `imports`, `import_map.json`)
|
|
38
|
+
* names, as written: relative values, and root-relative `/…` ones. */
|
|
39
|
+
export function importMapLocalTargets(text) {
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(text);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const imports = parsed?.imports;
|
|
48
|
+
if (typeof imports !== "object" || imports === null)
|
|
49
|
+
return [];
|
|
50
|
+
return Object.values(imports).filter((v) => typeof v === "string" && (v.startsWith("./") || v.startsWith("../") || v.startsWith("/")));
|
|
51
|
+
}
|
|
52
|
+
function toPosix(p) {
|
|
53
|
+
return sep === "/" ? p : p.split(sep).join("/");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The directories, project-relative and sorted, that the functions under
|
|
57
|
+
* `fnRelPath` can load modules from: `fnRelPath` itself, and every
|
|
58
|
+
* directory a relative import or a local import-map entry leads to,
|
|
59
|
+
* transitively. `["."]` when the walk runs out of budget. A directory
|
|
60
|
+
* nested under another in the answer is dropped.
|
|
61
|
+
*/
|
|
62
|
+
export function functionsReach(root, fnRelPath) {
|
|
63
|
+
const rootAbs = resolve(root);
|
|
64
|
+
const dirs = new Set();
|
|
65
|
+
const queue = [resolve(rootAbs, fnRelPath)];
|
|
66
|
+
let budget = FILE_BUDGET;
|
|
67
|
+
const inRoot = (p) => p === rootAbs || p.startsWith(rootAbs + sep);
|
|
68
|
+
const enqueue = (target) => {
|
|
69
|
+
if (!inRoot(target) || !existsSync(target))
|
|
70
|
+
return;
|
|
71
|
+
let st;
|
|
72
|
+
try {
|
|
73
|
+
st = statSync(target);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const dir = st.isDirectory() ? target : dirname(target);
|
|
79
|
+
if (![...dirs].some((d) => dir === d || dir.startsWith(d + sep)))
|
|
80
|
+
queue.push(dir);
|
|
81
|
+
};
|
|
82
|
+
const follow = (fromDir, spec) => {
|
|
83
|
+
if (spec.startsWith("./") || spec.startsWith("../"))
|
|
84
|
+
enqueue(resolve(fromDir, spec));
|
|
85
|
+
else if (spec.startsWith("/"))
|
|
86
|
+
enqueue(resolve(rootAbs, spec.slice(1)));
|
|
87
|
+
};
|
|
88
|
+
while (queue.length > 0) {
|
|
89
|
+
const dir = queue.shift();
|
|
90
|
+
if ([...dirs].some((d) => dir === d || dir.startsWith(d + sep)))
|
|
91
|
+
continue;
|
|
92
|
+
if (!existsSync(dir))
|
|
93
|
+
continue;
|
|
94
|
+
dirs.add(dir);
|
|
95
|
+
// Every file below it, parsed for what it imports.
|
|
96
|
+
const stack = [dir];
|
|
97
|
+
while (stack.length > 0) {
|
|
98
|
+
const d = stack.pop();
|
|
99
|
+
let entries;
|
|
100
|
+
try {
|
|
101
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
for (const e of entries) {
|
|
107
|
+
const p = join(d, e.name);
|
|
108
|
+
if (e.isDirectory()) {
|
|
109
|
+
if (!SKIP.has(e.name))
|
|
110
|
+
stack.push(p);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!e.isFile())
|
|
114
|
+
continue;
|
|
115
|
+
if (--budget < 0)
|
|
116
|
+
return ["."];
|
|
117
|
+
if (e.name === "deno.json" || e.name === "import_map.json") {
|
|
118
|
+
for (const t of importMapLocalTargets(safeRead(p)))
|
|
119
|
+
follow(d, t.endsWith("/") ? t.slice(0, -1) : t);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!SOURCE_EXT.test(e.name))
|
|
123
|
+
continue;
|
|
124
|
+
for (const spec of importSpecifiers(safeRead(p)))
|
|
125
|
+
follow(d, spec);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Minimal, project-relative, sorted.
|
|
130
|
+
const rel = [...dirs].map((d) => (d === rootAbs ? "." : toPosix(relative(rootAbs, d))));
|
|
131
|
+
const minimal = rel.filter((d) => !rel.some((o) => o !== d && (o === "." || d.startsWith(o + "/"))));
|
|
132
|
+
return [...new Set(minimal)].sort();
|
|
133
|
+
}
|
|
134
|
+
function safeRead(p) {
|
|
135
|
+
try {
|
|
136
|
+
return readFileSync(p, "utf8");
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** The `exclude` list that narrows a root build context to `dirs`: drop
|
|
143
|
+
* everything, then re-include each directory and its subtree, then drop
|
|
144
|
+
* again what no context should carry (a re-included `packages/shared`
|
|
145
|
+
* would otherwise bring its `node_modules` along). */
|
|
146
|
+
export function excludeAllBut(dirs) {
|
|
147
|
+
if (dirs.includes("."))
|
|
148
|
+
return [];
|
|
149
|
+
return ["**", ...dirs.flatMap((d) => [`!${d}`, `!${d}/**`]), ...REEXCLUDE];
|
|
150
|
+
}
|
|
151
|
+
/** Re-excluded after the re-includes: the names the default ignore drops. */
|
|
152
|
+
const REEXCLUDE = ["**/node_modules/**", "**/.git/**", "**/.spectest/**", "**/__pycache__/**", "**/.venv/**"];
|
|
153
|
+
// Keep `isAbsolute` referenced for callers that pass absolute fnRelPath by
|
|
154
|
+
// mistake: the walk resolves it against the root either way.
|
|
155
|
+
void isAbsolute;
|