@specific.dev/spectest 0.87.0 → 0.88.1
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/emulate/service.js +3 -4
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/microsoft.d.ts +2 -2
- package/dist/components/microsoft.js +5 -6
- 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 +34 -2
- 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/reach.d.ts +4 -0
- package/dist/harness/reach.js +2 -2
- package/dist/index.d.ts +27 -1
- package/dist/index.js +6 -1
- package/dist/project-files.js +6 -6
- 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/emulate/entry.mjs +5 -7
- package/src/components/emulate/service.ts +3 -4
- package/src/components/index.ts +1 -0
- package/src/components/microsoft.ts +7 -8
- 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 +43 -6
- package/src/desktop-recorder.test.ts +55 -0
- package/src/desktop-recorder.ts +30 -0
- package/src/desktop.ts +43 -0
- package/src/harness/reach.test.ts +12 -0
- package/src/harness/reach.ts +6 -2
- package/src/index.ts +34 -1
- package/src/inputs.test.ts +20 -0
- package/src/project-files.ts +6 -6
- package/src/recorder.ts +2 -0
package/src/browser.ts
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
|
|
@@ -34,6 +34,9 @@ import path from "node:path";
|
|
|
34
34
|
import { fileURLToPath } from "node:url";
|
|
35
35
|
|
|
36
36
|
import { generateId } from "./ids.js";
|
|
37
|
+
import { rawFetch } from "./harness/raw-fetch.js";
|
|
38
|
+
import { DESKTOP_INPUT_SYNC_SCRIPT } from "./desktop-recorder.js";
|
|
39
|
+
import type { DesktopChrome } from "./desktop.js";
|
|
37
40
|
import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
38
41
|
import { wrap } from "./inspect.js";
|
|
39
42
|
import type { Wrapped } from "./inspect.js";
|
|
@@ -150,6 +153,7 @@ export interface BrowserSessionRecorder {
|
|
|
150
153
|
* from the persisted events alone, with no replay bundle).
|
|
151
154
|
*/
|
|
152
155
|
readonly sessionName?: string;
|
|
156
|
+
readonly frame?: "browser" | "mobile" | "desktop";
|
|
153
157
|
/** Called for each drained chunk of rrweb events. */
|
|
154
158
|
recordStep(step: BrowserSessionStep): void;
|
|
155
159
|
/** Optional: called whenever `Browser.goto(url)` is invoked. */
|
|
@@ -1144,7 +1148,7 @@ const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
|
|
|
1144
1148
|
// snapshot keeps steady-state steps (clicks/types on an unchanged page,
|
|
1145
1149
|
// or navigations where `load` already fired) from bloating the stream
|
|
1146
1150
|
// with redundant snapshots.
|
|
1147
|
-
function drainExpr(forceFullIfMissing: boolean): string {
|
|
1151
|
+
function drainExpr(forceFullIfMissing: boolean, timestamp = Date.now()): string {
|
|
1148
1152
|
return `(function () {
|
|
1149
1153
|
try {
|
|
1150
1154
|
var rec = window.__spectestRec;
|
|
@@ -1159,6 +1163,9 @@ function drainExpr(forceFullIfMissing: boolean): string {
|
|
|
1159
1163
|
if (!hasFull) rec.takeFullSnapshot();
|
|
1160
1164
|
}
|
|
1161
1165
|
} catch (e) { /* recording inactive or DOM detached — drain what's there. */ }
|
|
1166
|
+
if (typeof window.__spectestSyncInputs === "function") {
|
|
1167
|
+
window.__spectestSyncInputs(${timestamp});
|
|
1168
|
+
}
|
|
1162
1169
|
var buf = window.__spectestRrwebEvents;
|
|
1163
1170
|
if (!buf || !buf.length) return [];
|
|
1164
1171
|
window.__spectestRrwebEvents = [];
|
|
@@ -1551,6 +1558,92 @@ export async function acquirePersistentMobileBackend(
|
|
|
1551
1558
|
return { browser: backend, attached: existing !== undefined, detach };
|
|
1552
1559
|
}
|
|
1553
1560
|
|
|
1561
|
+
/** Live CDP clients and window identities ride the harness snapshot. */
|
|
1562
|
+
const ELECTRON_APPS = new Map<string, Promise<{
|
|
1563
|
+
browser: PlaywrightBrowser;
|
|
1564
|
+
context: BrowserContext;
|
|
1565
|
+
windows: Page[];
|
|
1566
|
+
holders: Map<Page, ViewHolder>;
|
|
1567
|
+
}>>();
|
|
1568
|
+
|
|
1569
|
+
/** Attach the shared locator/recorder backend to an actual Electron window.
|
|
1570
|
+
* Never navigate, reload, emulate a viewport, or replace its renderer. */
|
|
1571
|
+
export async function acquirePersistentDesktop(
|
|
1572
|
+
endpoint: string,
|
|
1573
|
+
windowIndex: number,
|
|
1574
|
+
recorder: BrowserSessionRecorder | null,
|
|
1575
|
+
): Promise<PersistentBrowser & { desktopChrome?: DesktopChrome }> {
|
|
1576
|
+
if (!Number.isInteger(windowIndex) || windowIndex < 0) {
|
|
1577
|
+
throw new Error("ctx.desktop(): window must be a non-negative integer (creation order)");
|
|
1578
|
+
}
|
|
1579
|
+
let pending = ELECTRON_APPS.get(endpoint);
|
|
1580
|
+
if (!pending) {
|
|
1581
|
+
pending = (async () => {
|
|
1582
|
+
const response = await rawFetch(new URL("/json/version", endpoint), {
|
|
1583
|
+
signal: AbortSignal.timeout(15_000),
|
|
1584
|
+
});
|
|
1585
|
+
if (!response.ok) throw new Error(`Electron debugger discovery failed: HTTP ${response.status}`);
|
|
1586
|
+
const info = await response.json() as { webSocketDebuggerUrl: string };
|
|
1587
|
+
const websocket = new URL(info.webSocketDebuggerUrl);
|
|
1588
|
+
websocket.host = new URL(endpoint).host;
|
|
1589
|
+
const browser = await chromium.connectOverCDP(websocket.toString(), { timeout: 15_000 });
|
|
1590
|
+
const context = browser.contexts()[0];
|
|
1591
|
+
if (!context) { await browser.close(); throw new Error("Electron has no renderer context"); }
|
|
1592
|
+
const windows = context.pages().filter(page => !page.url().startsWith("devtools:"));
|
|
1593
|
+
context.on("page", page => { if (!page.url().startsWith("devtools:")) windows.push(page); });
|
|
1594
|
+
browser.on("disconnected", () => { ELECTRON_APPS.delete(endpoint); });
|
|
1595
|
+
return { browser, context, windows, holders: new Map<Page, ViewHolder>() };
|
|
1596
|
+
})();
|
|
1597
|
+
ELECTRON_APPS.set(endpoint, pending);
|
|
1598
|
+
pending.catch(() => { if (ELECTRON_APPS.get(endpoint) === pending) ELECTRON_APPS.delete(endpoint); });
|
|
1599
|
+
}
|
|
1600
|
+
const app = await pending;
|
|
1601
|
+
const deadline = Date.now() + 15_000;
|
|
1602
|
+
while (!app.windows[windowIndex]) {
|
|
1603
|
+
const remaining = deadline - Date.now();
|
|
1604
|
+
if (remaining <= 0) throw new Error(`Electron window ${windowIndex} did not open`);
|
|
1605
|
+
await app.context.waitForEvent("page", { timeout: remaining });
|
|
1606
|
+
}
|
|
1607
|
+
const page = app.windows[windowIndex];
|
|
1608
|
+
if (page.isClosed()) throw new Error(`Electron window ${windowIndex} is closed; window indices retain creation order`);
|
|
1609
|
+
await page.waitForLoadState("domcontentloaded");
|
|
1610
|
+
// The main-process observer publishes only window presentation options.
|
|
1611
|
+
// A popup created internally by Chromium might not pass through BrowserWindow.
|
|
1612
|
+
await page.waitForFunction(() => Boolean((window as any).__spectestElectronChrome), null, { timeout: 3000 }).catch(() => {});
|
|
1613
|
+
const desktopChrome = await page.evaluate(() => {
|
|
1614
|
+
const chrome = (window as any).__spectestElectronChrome;
|
|
1615
|
+
if (!chrome) return undefined;
|
|
1616
|
+
const overlay = (navigator as any).windowControlsOverlay;
|
|
1617
|
+
return { ...chrome, ...(overlay?.visible ? { titlebarArea: overlay.getTitlebarAreaRect().toJSON() } : {}) };
|
|
1618
|
+
}) as DesktopChrome | undefined;
|
|
1619
|
+
let holder = app.holders.get(page);
|
|
1620
|
+
const attached = holder !== undefined;
|
|
1621
|
+
if (!holder) {
|
|
1622
|
+
const cdp = await app.context.newCDPSession(page);
|
|
1623
|
+
await cdp.send("Page.enable");
|
|
1624
|
+
if (!PAGE_INIT_SCRIPT) throw new Error("Electron replay recorder bundle is missing");
|
|
1625
|
+
const init = `${PAGE_INIT_SCRIPT};\n${DESKTOP_INPUT_SYNC_SCRIPT}`;
|
|
1626
|
+
await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: init });
|
|
1627
|
+
// The app owns navigation and may already have loaded. Start on its live
|
|
1628
|
+
// DOM, without losing drafts or replaying any application startup effects.
|
|
1629
|
+
await page.evaluate(init);
|
|
1630
|
+
const size = await page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
|
|
1631
|
+
holder = {
|
|
1632
|
+
context: app.context, page, cdp, lastTitle: await page.title(),
|
|
1633
|
+
recordingInstalled: true, device: null, width: size.width, height: size.height,
|
|
1634
|
+
safeAreaInsets: null, initScripts: [],
|
|
1635
|
+
};
|
|
1636
|
+
app.holders.set(page, holder);
|
|
1637
|
+
page.on("close", () => { app.holders.delete(page); });
|
|
1638
|
+
}
|
|
1639
|
+
await attachReset(holder);
|
|
1640
|
+
const { backend, detach } = buildBackend(holder, recorder, {
|
|
1641
|
+
persistent: false, ownsContext: false,
|
|
1642
|
+
onDestroy: () => { app.holders.delete(page); },
|
|
1643
|
+
});
|
|
1644
|
+
return { browser: backend, attached, detach, desktopChrome };
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1554
1647
|
/** True when a navigation failed on Chromium name resolution. */
|
|
1555
1648
|
function isNameNotResolved(err: unknown): boolean {
|
|
1556
1649
|
return String((err as Error)?.message ?? err).includes("ERR_NAME_NOT_RESOLVED");
|
|
@@ -1617,6 +1710,8 @@ interface BackendBuildOptions {
|
|
|
1617
1710
|
/** Called when close() destroys the underlying view — the acquire
|
|
1618
1711
|
* functions use it to drop the holder from the shared registry. */
|
|
1619
1712
|
onDestroy?: () => void;
|
|
1713
|
+
/** Electron windows share an app context; closing one must leave siblings alive. */
|
|
1714
|
+
ownsContext?: boolean;
|
|
1620
1715
|
}
|
|
1621
1716
|
|
|
1622
1717
|
function buildBackend(
|
|
@@ -1678,15 +1773,16 @@ function buildBackend(
|
|
|
1678
1773
|
return {
|
|
1679
1774
|
sessionId: recorder.sessionId,
|
|
1680
1775
|
sessionTimestamp: endT,
|
|
1776
|
+
...(recorder.frame ? { frame: recorder.frame } : {}),
|
|
1681
1777
|
...(recorder.sessionName ? { sessionName: recorder.sessionName } : {}),
|
|
1682
1778
|
};
|
|
1683
1779
|
}
|
|
1684
1780
|
|
|
1685
|
-
async function drain(action: BrowserAction | "close"): Promise<void> {
|
|
1781
|
+
async function drain(action: BrowserAction | "close", timestamp = Date.now()): Promise<void> {
|
|
1686
1782
|
if (!holder.recordingInstalled || !recorder || recordingEnded) return;
|
|
1687
1783
|
try {
|
|
1688
1784
|
const urlChanged = holder.page.url() !== lastDrainUrl;
|
|
1689
|
-
const events = (await holder.page.evaluate(drainExpr(urlChanged))) as unknown[];
|
|
1785
|
+
const events = (await holder.page.evaluate(drainExpr(urlChanged, timestamp))) as unknown[];
|
|
1690
1786
|
lastDrainUrl = holder.page.url();
|
|
1691
1787
|
if (Array.isArray(events) && events.length > 0) {
|
|
1692
1788
|
recorder.recordStep({
|
|
@@ -1731,7 +1827,7 @@ function buildBackend(
|
|
|
1731
1827
|
...sessionFields(endT),
|
|
1732
1828
|
durationMs: endT - t,
|
|
1733
1829
|
}, resv);
|
|
1734
|
-
await drain(action);
|
|
1830
|
+
await drain(action, endT);
|
|
1735
1831
|
// Fold this page's V8 counters into the per-service totals now — a
|
|
1736
1832
|
// navigation to another origin replaces the renderer and its
|
|
1737
1833
|
// counters, so harvesting per op is what keeps them.
|
|
@@ -1766,7 +1862,7 @@ function buildBackend(
|
|
|
1766
1862
|
}, resv);
|
|
1767
1863
|
// Still try to drain — the failure itself may have produced
|
|
1768
1864
|
// useful rrweb events (mutations from a half-loaded page, etc.).
|
|
1769
|
-
await drain(action);
|
|
1865
|
+
await drain(action, endT);
|
|
1770
1866
|
throw err;
|
|
1771
1867
|
}
|
|
1772
1868
|
}
|
|
@@ -2067,7 +2163,7 @@ function buildBackend(
|
|
|
2067
2163
|
// The context, not the page, holds the cookies and storage — closing
|
|
2068
2164
|
// only the page would leave the next session signed in as this one.
|
|
2069
2165
|
try {
|
|
2070
|
-
await holder.context.close();
|
|
2166
|
+
if (buildOpts.ownsContext !== false) await holder.context.close();
|
|
2071
2167
|
} catch {
|
|
2072
2168
|
/* context already gone (browser died) */
|
|
2073
2169
|
}
|
|
@@ -2149,7 +2245,7 @@ function buildBackend(
|
|
|
2149
2245
|
}, reserveBackdated(waitedMs));
|
|
2150
2246
|
// Drain the rrweb the page buffered while the matcher waited into this
|
|
2151
2247
|
// step's chunk, so `settledTarget` has bounds to seek into.
|
|
2152
|
-
await drain(action);
|
|
2248
|
+
await drain(action, endT);
|
|
2153
2249
|
return seq;
|
|
2154
2250
|
},
|
|
2155
2251
|
pageOp<T>(
|
package/src/components/aws.ts
CHANGED
|
@@ -249,6 +249,12 @@ export function aws(opts: AwsOptions = {}) {
|
|
|
249
249
|
LAMBDA_DOCKER_NETWORK: SPECTEST_NETWORK,
|
|
250
250
|
},
|
|
251
251
|
volumes: [{ source: DOCKER_SOCKET, target: DOCKER_SOCKET }],
|
|
252
|
+
// Each lambda's code is packaged from the project at setup: no build
|
|
253
|
+
// context or volume carries it, so it is declared as an input and a
|
|
254
|
+
// change to it is never taken as one that can affect no test.
|
|
255
|
+
...(Object.keys(lambdas).length > 0
|
|
256
|
+
? { inputs: Object.values(lambdas).map((l) => l.source).filter((s) => typeof s === "string" && s.trim() !== "") }
|
|
257
|
+
: {}),
|
|
252
258
|
ports: [AWS_PORT],
|
|
253
259
|
readyCheck: {
|
|
254
260
|
type: "http" as const,
|
|
@@ -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,38 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { electron } from './electron.js';
|
|
6
|
+
import { desktopApp, isDesktopApp } from '../desktop.js';
|
|
7
|
+
|
|
8
|
+
function fixture(run: (directory: string) => void) {
|
|
9
|
+
const directory = mkdtempSync(join(tmpdir(), 'spectest-electron-'));
|
|
10
|
+
try {
|
|
11
|
+
writeFileSync(join(directory, 'package.json'), JSON.stringify({ devDependencies: { electron: '43.2.0' } }));
|
|
12
|
+
run(directory);
|
|
13
|
+
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('Electron component contract', () => {
|
|
17
|
+
test('rejects apps without Electron before starting an expensive build', () => fixture(appDir => {
|
|
18
|
+
writeFileSync(join(appDir, 'package.json'), '{}');
|
|
19
|
+
expect(() => electron({ appDir })).toThrow('declare electron');
|
|
20
|
+
}));
|
|
21
|
+
test('honors locked installation, custom build commands, and app environment', () => fixture(appDir => {
|
|
22
|
+
writeFileSync(join(appDir, 'package-lock.json'), '{}');
|
|
23
|
+
const service = electron({ appDir, buildCommand: 'npm run compile', env: { API_URL: 'http://backend:4320' } });
|
|
24
|
+
expect(service.image).toMatchObject({ context: appDir });
|
|
25
|
+
expect((service.image as { content: string }).content).toContain('RUN npm ci\n');
|
|
26
|
+
expect((service.image as { content: string }).content).toContain('RUN npm run compile\n');
|
|
27
|
+
expect(service.env?.API_URL).toBe('http://backend:4320');
|
|
28
|
+
}));
|
|
29
|
+
test('rejects malformed image tags and multiline Dockerfile injection', () => fixture(appDir => {
|
|
30
|
+
expect(() => electron({ appDir, nodeVersion: '24\nRUN false' })).toThrow('image tag');
|
|
31
|
+
expect(() => electron({ appDir, buildCommand: 'npm run build\nUSER other' })).toThrow('single-line');
|
|
32
|
+
}));
|
|
33
|
+
test('desktop identity cannot be mistaken for a URL or browser/mobile handle', () => {
|
|
34
|
+
expect(isDesktopApp(desktopApp('http://app.internal:9223', 'app'))).toBe(true);
|
|
35
|
+
expect(isDesktopApp({ url: 'http://app.internal:8081' })).toBe(false);
|
|
36
|
+
expect(isDesktopApp(null)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import type { ServiceDefinition } from "../index.js";
|
|
3
|
+
import { resolveProjectPath } from "../project-files.js";
|
|
4
|
+
import { desktopApp, type DesktopApp } from "../desktop.js";
|
|
5
|
+
import { ELECTRON_CHROME_HOOK } from "./electron-chrome.js";
|
|
6
|
+
|
|
7
|
+
export interface ElectronOptions {
|
|
8
|
+
/** Project-relative app directory; must contain package.json and Electron. */
|
|
9
|
+
appDir?: string;
|
|
10
|
+
/** Debian Node image tag, default 24-bookworm-slim. */
|
|
11
|
+
nodeVersion?: string;
|
|
12
|
+
/** Build the main/preload/renderer output; default npm run build. */
|
|
13
|
+
buildCommand?: string;
|
|
14
|
+
/** Dependency install command; defaults to npm ci when a lockfile exists. */
|
|
15
|
+
installCommand?: string;
|
|
16
|
+
/** App entry relative to /app, default . (package.json main). */
|
|
17
|
+
entry?: string;
|
|
18
|
+
env?: Record<string, string>;
|
|
19
|
+
dependsOn?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Test-only debugger proxy: Electron binds its CDP socket to loopback. Forward
|
|
23
|
+
// HTTP and WebSocket traffic from the VM to that socket, normalizing Host as
|
|
24
|
+
// Chromium requires. It exposes no app-specific actions or state endpoints.
|
|
25
|
+
export const ELECTRON_LAUNCHER = String.raw`
|
|
26
|
+
import http from 'node:http';
|
|
27
|
+
import { spawn } from 'node:child_process';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
const require = createRequire('/app/package.json');
|
|
30
|
+
const child = spawn(require('electron'), [
|
|
31
|
+
...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
|
|
32
|
+
'--disable-dev-shm-usage', '--remote-debugging-port=9222',
|
|
33
|
+
'-r', '/spectest-electron-chrome.cjs',
|
|
34
|
+
process.env.SPECTEST_ELECTRON_ENTRY || '.',
|
|
35
|
+
], { cwd: '/app', stdio: 'inherit', env: process.env });
|
|
36
|
+
const upstream = req => ({
|
|
37
|
+
host: '127.0.0.1', port: 9222, path: req.url, method: req.method,
|
|
38
|
+
headers: { ...req.headers, host: '127.0.0.1:9222' },
|
|
39
|
+
});
|
|
40
|
+
const server = http.createServer((req, res) => {
|
|
41
|
+
const proxy = http.request(upstream(req), response => {
|
|
42
|
+
res.writeHead(response.statusCode, response.headers);
|
|
43
|
+
response.pipe(res);
|
|
44
|
+
});
|
|
45
|
+
proxy.on('error', () => { res.writeHead(503); res.end('Electron debugger starting'); });
|
|
46
|
+
req.pipe(proxy);
|
|
47
|
+
});
|
|
48
|
+
server.on('upgrade', (req, socket, head) => {
|
|
49
|
+
const proxy = http.request(upstream(req));
|
|
50
|
+
proxy.on('upgrade', (response, remote, remoteHead) => {
|
|
51
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\n' +
|
|
52
|
+
Object.entries(response.headers).map(([k, v]) => k + ': ' + v).join('\r\n') + '\r\n\r\n');
|
|
53
|
+
if (remoteHead.length) socket.write(remoteHead);
|
|
54
|
+
if (head.length) remote.write(head);
|
|
55
|
+
socket.pipe(remote); remote.pipe(socket);
|
|
56
|
+
socket.on('error', () => remote.destroy());
|
|
57
|
+
remote.on('error', () => socket.destroy());
|
|
58
|
+
socket.on('close', () => remote.destroy());
|
|
59
|
+
remote.on('close', () => socket.destroy());
|
|
60
|
+
});
|
|
61
|
+
proxy.on('response', () => socket.destroy());
|
|
62
|
+
proxy.on('error', () => socket.destroy());
|
|
63
|
+
proxy.end();
|
|
64
|
+
});
|
|
65
|
+
server.listen(9223, '0.0.0.0');
|
|
66
|
+
child.on('error', error => { console.error(error); process.exit(1); });
|
|
67
|
+
child.on('exit', code => process.exit(code ?? 1));
|
|
68
|
+
for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => child.kill(signal));
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
/** Real Linux Electron under Xvfb, with a generated image and graphical replay.
|
|
72
|
+
* Like expo(), returns an app handle for the matching context method.
|
|
73
|
+
* Requires Linux-compatible application dependencies and binaries. */
|
|
74
|
+
export function electron(options: ElectronOptions = {}) {
|
|
75
|
+
const appDir = options.appDir ?? ".";
|
|
76
|
+
const manifest = resolveProjectPath(`${appDir}/package.json`);
|
|
77
|
+
if (!existsSync(manifest)) throw new Error(`electron(): no package.json in ${appDir}`);
|
|
78
|
+
const pkg = JSON.parse(readFileSync(manifest, "utf8"));
|
|
79
|
+
if (!pkg.dependencies?.electron && !pkg.devDependencies?.electron) {
|
|
80
|
+
throw new Error("electron(): declare electron in the app's dependencies or devDependencies");
|
|
81
|
+
}
|
|
82
|
+
const locked = existsSync(resolveProjectPath(`${appDir}/package-lock.json`));
|
|
83
|
+
const node = options.nodeVersion ?? "24-bookworm-slim";
|
|
84
|
+
if (!/^[\w.-]+$/.test(node)) throw new Error("electron(): invalid Node image tag");
|
|
85
|
+
const install = options.installCommand ?? (locked ? "npm ci" : "npm install");
|
|
86
|
+
const build = options.buildCommand ?? "npm run build";
|
|
87
|
+
if ([install, build].some(command => /[\r\n]/.test(command))) {
|
|
88
|
+
throw new Error("electron(): build/install commands must be single-line shell commands");
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
image: {
|
|
92
|
+
type: "dockerfile",
|
|
93
|
+
context: appDir,
|
|
94
|
+
exclude: ["node_modules", "**/node_modules", "out", "test-results", ".data"],
|
|
95
|
+
content: `FROM node:${node}
|
|
96
|
+
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/*
|
|
97
|
+
WORKDIR /app
|
|
98
|
+
COPY . .
|
|
99
|
+
RUN ${install}
|
|
100
|
+
RUN node node_modules/electron/install.js
|
|
101
|
+
RUN ${build}
|
|
102
|
+
`,
|
|
103
|
+
},
|
|
104
|
+
files: [
|
|
105
|
+
{ path: "/spectest-electron.mjs", content: ELECTRON_LAUNCHER },
|
|
106
|
+
{ path: "/spectest-electron-chrome.cjs", content: ELECTRON_CHROME_HOOK },
|
|
107
|
+
],
|
|
108
|
+
command: "xvfb-run -a node /spectest-electron.mjs",
|
|
109
|
+
env: { ...options.env, SPECTEST_ELECTRON_ENTRY: options.entry ?? "." },
|
|
110
|
+
dependsOn: options.dependsOn,
|
|
111
|
+
ports: [9223],
|
|
112
|
+
readyCheck: { type: "http", port: 9223, path: "/json/version", timeoutSecs: 60 },
|
|
113
|
+
helpers: ({ name }: { name: string }) => desktopApp(`http://${name}.internal:9223`, name),
|
|
114
|
+
} satisfies ServiceDefinition<DesktopApp>;
|
|
115
|
+
}
|
|
@@ -17,13 +17,11 @@
|
|
|
17
17
|
// origin. Real providers spread them over several hosts. We merge the
|
|
18
18
|
// real URLs in.
|
|
19
19
|
// * JWKS — we serve one RSA public key, for every provider.
|
|
20
|
-
// * id_token — we re-issue it: same claims, RS256, our key, and
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// And any future provider whose issuer does not match its base URL is
|
|
26
|
-
// covered in advance. No signature check is needed on the way in: the
|
|
20
|
+
// * id_token — we re-issue it: same claims, RS256, our key, and an
|
|
21
|
+
// explicit `iss` where the provider declares one. The Google emulator
|
|
22
|
+
// signs HS256 and serves an empty JWKS, so no client can verify its
|
|
23
|
+
// token. Providers whose issuer differs from their base URL can
|
|
24
|
+
// declare it explicitly. No signature check is needed on the way in: the
|
|
27
25
|
// token comes from an in-process function call, not from the network.
|
|
28
26
|
// * the account picker page — each button gets a `data-testid`, so a
|
|
29
27
|
// browser test has a stable locator.
|
|
@@ -26,11 +26,10 @@
|
|
|
26
26
|
// `entry.mjs` routes by Host header, applies the per-provider path table,
|
|
27
27
|
// and corrects the discovery document. It also re-issues every id_token
|
|
28
28
|
// with one RSA key it generates at boot, and serves that key at the
|
|
29
|
-
// provider's real JWKS URL.
|
|
29
|
+
// provider's real JWKS URL. This normalizes signing and issuer handling:
|
|
30
30
|
// the Google emulator signs HS256 and publishes an empty JWKS (so nothing
|
|
31
|
-
// can verify its token),
|
|
32
|
-
//
|
|
33
|
-
// handled in advance.
|
|
31
|
+
// can verify its token), and providers whose issuer differs from their
|
|
32
|
+
// base URL can declare it explicitly.
|
|
34
33
|
//
|
|
35
34
|
// The Dockerfile is deliberately CONSTANT — every provider package is
|
|
36
35
|
// installed whether or not this component is the one using it. Two reasons:
|
package/src/components/index.ts
CHANGED
|
@@ -13,23 +13,22 @@ import { emulatorService, oauthClients, resolveUsers } from "./emulate/service.j
|
|
|
13
13
|
export interface MicrosoftOptions extends ProviderOptions {
|
|
14
14
|
/**
|
|
15
15
|
* Directory (tenant) the app signs in against — whatever its authority
|
|
16
|
-
* URL uses.
|
|
17
|
-
*
|
|
16
|
+
* URL uses. Sets the seeded users' and client's tenant as well as the
|
|
17
|
+
* issuer and endpoint paths. Default `"common"`.
|
|
18
18
|
*/
|
|
19
19
|
tenantId?: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function spec(users: ProviderUser[], opts: MicrosoftOptions): ProviderSpec {
|
|
23
|
-
const
|
|
23
|
+
const tenantId = opts.tenantId || "common";
|
|
24
|
+
const authority = `https://login.microsoftonline.com/${tenantId}`;
|
|
24
25
|
return {
|
|
25
26
|
name: "microsoft",
|
|
26
27
|
module: "@emulators/microsoft",
|
|
27
28
|
pluginExport: "microsoftPlugin",
|
|
28
29
|
baseUrl: "https://login.microsoftonline.com",
|
|
29
30
|
hosts: ["login.microsoftonline.com", "graph.microsoft.com"],
|
|
30
|
-
//
|
|
31
|
-
// that checks the issuer against the authority it configured would
|
|
32
|
-
// reject that; the re-issue step corrects it.
|
|
31
|
+
// Keep the re-issued token aligned with discovery and the seeded tenant.
|
|
33
32
|
issuer: `${authority}/v2.0`,
|
|
34
33
|
discovery: {
|
|
35
34
|
issuer: `${authority}/v2.0`,
|
|
@@ -52,8 +51,8 @@ function spec(users: ProviderUser[], opts: MicrosoftOptions): ProviderSpec {
|
|
|
52
51
|
scopes: ["openid", "email", "profile", "User.Read"],
|
|
53
52
|
},
|
|
54
53
|
seed: {
|
|
55
|
-
users: users.map((u) => ({ email: u.email, name: u.name })),
|
|
56
|
-
...oauthClients(opts.client),
|
|
54
|
+
users: users.map((u) => ({ email: u.email, name: u.name, tenant_id: tenantId })),
|
|
55
|
+
...oauthClients(opts.client, { tenant_id: tenantId }),
|
|
57
56
|
},
|
|
58
57
|
};
|
|
59
58
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { excludeAllBut, functionsReach, importMapLocalTargets, importSpecifiers } from "./supabase-functions-reach.js";
|
|
7
|
+
|
|
8
|
+
let root: string;
|
|
9
|
+
|
|
10
|
+
function file(rel: string, text: string): void {
|
|
11
|
+
mkdirSync(join(root, rel, ".."), { recursive: true });
|
|
12
|
+
writeFileSync(join(root, rel), text);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
root = mkdtempSync(join(tmpdir(), "sb-fn-reach-"));
|
|
17
|
+
file("supabase/functions/hello/index.ts", 'import { greet } from "../../../packages/shared/src/greet.ts";\nimport "npm:zod";\nDeno.serve(() => new Response(greet()));\n');
|
|
18
|
+
file("supabase/functions/bye/index.ts", 'import { db } from "@db/client.ts";\nexport default 1;\n');
|
|
19
|
+
file("supabase/functions/deno.json", JSON.stringify({ imports: { "@db/": "../../lib/db/", "zod": "npm:zod" } }));
|
|
20
|
+
file("packages/shared/src/greet.ts", 'import { fmt } from "./fmt.ts";\nimport { util } from "../../util/mod.ts";\nexport const greet = () => fmt(util());\n');
|
|
21
|
+
file("packages/shared/src/fmt.ts", "export const fmt = (s: string) => s;\n");
|
|
22
|
+
file("packages/util/mod.ts", "export const util = () => 'x';\n");
|
|
23
|
+
file("lib/db/client.ts", "export const db = 1;\n");
|
|
24
|
+
file("docs/guide.md", "# not code\n");
|
|
25
|
+
file("site/app/page.tsx", 'import x from "../../packages/shared/src/greet.ts";\n');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
29
|
+
|
|
30
|
+
describe("functionsReach", () => {
|
|
31
|
+
test("follows relative imports and local import-map entries transitively, into directories", () => {
|
|
32
|
+
expect(functionsReach(root, "supabase/functions")).toEqual(["lib/db", "packages/shared/src", "packages/util", "supabase/functions"]);
|
|
33
|
+
});
|
|
34
|
+
test("a self-contained functions directory is the whole answer; a missing one is nothing", () => {
|
|
35
|
+
file("alone/functions/f/index.ts", 'import "https://deno.land/std/http/server.ts";\nimport("./helper.ts");\n');
|
|
36
|
+
expect(functionsReach(root, "alone/functions")).toEqual(["alone/functions"]);
|
|
37
|
+
expect(functionsReach(root, "nowhere/functions")).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
test("an import that leaves the project is ignored", () => {
|
|
40
|
+
file("escape/functions/f/index.ts", 'import "../../../../../../etc/passwd";\n');
|
|
41
|
+
expect(functionsReach(root, "escape/functions")).toEqual(["escape/functions"]);
|
|
42
|
+
});
|
|
43
|
+
test("the exclude list drops everything but the answer", () => {
|
|
44
|
+
expect(excludeAllBut(["lib/db", "supabase/functions"])).toEqual([
|
|
45
|
+
"**",
|
|
46
|
+
"!lib/db",
|
|
47
|
+
"!lib/db/**",
|
|
48
|
+
"!supabase/functions",
|
|
49
|
+
"!supabase/functions/**",
|
|
50
|
+
"**/node_modules/**",
|
|
51
|
+
"**/.git/**",
|
|
52
|
+
"**/.spectest/**",
|
|
53
|
+
"**/__pycache__/**",
|
|
54
|
+
"**/.venv/**",
|
|
55
|
+
]);
|
|
56
|
+
expect(excludeAllBut(["."])).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("parsers", () => {
|
|
61
|
+
test("import specifiers of every shape", () => {
|
|
62
|
+
const t = 'import a from "./a.ts";\nexport * from "../b.ts";\nimport "./side.ts";\nconst m = await import("./dyn.ts");\nimport type { T } from "npm:x";\n';
|
|
63
|
+
expect(importSpecifiers(t)).toEqual(["./a.ts", "../b.ts", "./side.ts", "./dyn.ts", "npm:x"]);
|
|
64
|
+
});
|
|
65
|
+
test("import map local targets", () => {
|
|
66
|
+
expect(importMapLocalTargets('{"imports":{"@a/":"../../a/","b":"npm:b","c":"/c/mod.ts"}}')).toEqual(["../../a/", "/c/mod.ts"]);
|
|
67
|
+
expect(importMapLocalTargets("not json")).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("supabase() wiring", () => {
|
|
72
|
+
test("the functions image context is the module graph, and the root mount is reach-exempt", async () => {
|
|
73
|
+
const prev = process.env.SPECTEST_WORKSPACE;
|
|
74
|
+
process.env.SPECTEST_WORKSPACE = root;
|
|
75
|
+
try {
|
|
76
|
+
const { supabase } = await import("./supabase.js");
|
|
77
|
+
const stack = supabase({ dir: "supabase" });
|
|
78
|
+
const parts = stack.group.services({ name: "sb", key: (part: string) => (part === "gateway" ? "sb" : `sb-${part}`) });
|
|
79
|
+
const fn = parts.functions as { image: { context?: string; exclude?: readonly string[] }; volumes?: readonly { source?: string; reachExempt?: boolean }[] };
|
|
80
|
+
expect(fn.image.context).toBe(".");
|
|
81
|
+
expect(fn.image.exclude?.slice(0, 9)).toEqual(["**", "!lib/db", "!lib/db/**", "!packages/shared/src", "!packages/shared/src/**", "!packages/util", "!packages/util/**", "!supabase/functions", "!supabase/functions/**"]);
|
|
82
|
+
expect(fn.image.exclude?.at(-1)).toBe("**/.venv/**");
|
|
83
|
+
expect(fn.volumes?.[0]).toMatchObject({ source: root, reachExempt: true });
|
|
84
|
+
expect((parts.db as { inputs?: readonly string[] }).inputs).toEqual(["supabase"]);
|
|
85
|
+
} finally {
|
|
86
|
+
if (prev === undefined) delete process.env.SPECTEST_WORKSPACE;
|
|
87
|
+
else process.env.SPECTEST_WORKSPACE = prev;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|