@specific.dev/spectest 0.87.0 → 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 +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 +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 +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 +33 -0
- package/src/inputs.test.ts +20 -0
- package/src/recorder.ts +2 -0
|
@@ -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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
|
|
22
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
23
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
24
|
+
|
|
25
|
+
/** Files the walk parses for imports. */
|
|
26
|
+
const SOURCE_EXT = /\.(?:[mc]?[jt]sx?)$/;
|
|
27
|
+
/** Names never walked into. */
|
|
28
|
+
const SKIP = new Set(["node_modules", ".git", ".spectest", "dist", "build", ".next"]);
|
|
29
|
+
/** The walk gives up past this many files and answers with the root. */
|
|
30
|
+
const FILE_BUDGET = 20_000;
|
|
31
|
+
|
|
32
|
+
const IMPORT_RE =
|
|
33
|
+
/\b(?:import|export)\b[^;'"`]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]|\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
34
|
+
|
|
35
|
+
/** Specifiers a source text imports, as written. */
|
|
36
|
+
export function importSpecifiers(text: string): string[] {
|
|
37
|
+
const out: string[] = [];
|
|
38
|
+
for (const m of text.matchAll(IMPORT_RE)) out.push(m[1] ?? m[2] ?? m[3] ?? "");
|
|
39
|
+
return out.filter((s) => s.length > 0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Local targets an import map (`deno.json` `imports`, `import_map.json`)
|
|
43
|
+
* names, as written: relative values, and root-relative `/…` ones. */
|
|
44
|
+
export function importMapLocalTargets(text: string): string[] {
|
|
45
|
+
let parsed: unknown;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(text);
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const imports = (parsed as { imports?: unknown })?.imports;
|
|
52
|
+
if (typeof imports !== "object" || imports === null) return [];
|
|
53
|
+
return Object.values(imports as Record<string, unknown>).filter(
|
|
54
|
+
(v): v is string => typeof v === "string" && (v.startsWith("./") || v.startsWith("../") || v.startsWith("/")),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toPosix(p: string): string {
|
|
59
|
+
return sep === "/" ? p : p.split(sep).join("/");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The directories, project-relative and sorted, that the functions under
|
|
64
|
+
* `fnRelPath` can load modules from: `fnRelPath` itself, and every
|
|
65
|
+
* directory a relative import or a local import-map entry leads to,
|
|
66
|
+
* transitively. `["."]` when the walk runs out of budget. A directory
|
|
67
|
+
* nested under another in the answer is dropped.
|
|
68
|
+
*/
|
|
69
|
+
export function functionsReach(root: string, fnRelPath: string): string[] {
|
|
70
|
+
const rootAbs = resolve(root);
|
|
71
|
+
const dirs = new Set<string>();
|
|
72
|
+
const queue: string[] = [resolve(rootAbs, fnRelPath)];
|
|
73
|
+
let budget = FILE_BUDGET;
|
|
74
|
+
|
|
75
|
+
const inRoot = (p: string): boolean => p === rootAbs || p.startsWith(rootAbs + sep);
|
|
76
|
+
const enqueue = (target: string): void => {
|
|
77
|
+
if (!inRoot(target) || !existsSync(target)) return;
|
|
78
|
+
let st;
|
|
79
|
+
try {
|
|
80
|
+
st = statSync(target);
|
|
81
|
+
} catch {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const dir = st.isDirectory() ? target : dirname(target);
|
|
85
|
+
if (![...dirs].some((d) => dir === d || dir.startsWith(d + sep))) queue.push(dir);
|
|
86
|
+
};
|
|
87
|
+
const follow = (fromDir: string, spec: string): void => {
|
|
88
|
+
if (spec.startsWith("./") || spec.startsWith("../")) enqueue(resolve(fromDir, spec));
|
|
89
|
+
else if (spec.startsWith("/")) enqueue(resolve(rootAbs, spec.slice(1)));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
while (queue.length > 0) {
|
|
93
|
+
const dir = queue.shift()!;
|
|
94
|
+
if ([...dirs].some((d) => dir === d || dir.startsWith(d + sep))) continue;
|
|
95
|
+
if (!existsSync(dir)) continue;
|
|
96
|
+
dirs.add(dir);
|
|
97
|
+
// Every file below it, parsed for what it imports.
|
|
98
|
+
const stack: string[] = [dir];
|
|
99
|
+
while (stack.length > 0) {
|
|
100
|
+
const d = stack.pop()!;
|
|
101
|
+
let entries;
|
|
102
|
+
try {
|
|
103
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
104
|
+
} catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
for (const e of entries) {
|
|
108
|
+
const p = join(d, e.name);
|
|
109
|
+
if (e.isDirectory()) {
|
|
110
|
+
if (!SKIP.has(e.name)) stack.push(p);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!e.isFile()) continue;
|
|
114
|
+
if (--budget < 0) return ["."];
|
|
115
|
+
if (e.name === "deno.json" || e.name === "import_map.json") {
|
|
116
|
+
for (const t of importMapLocalTargets(safeRead(p))) follow(d, t.endsWith("/") ? t.slice(0, -1) : t);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!SOURCE_EXT.test(e.name)) continue;
|
|
120
|
+
for (const spec of importSpecifiers(safeRead(p))) follow(d, spec);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Minimal, project-relative, sorted.
|
|
126
|
+
const rel = [...dirs].map((d) => (d === rootAbs ? "." : toPosix(relative(rootAbs, d))));
|
|
127
|
+
const minimal = rel.filter((d) => !rel.some((o) => o !== d && (o === "." || d.startsWith(o + "/"))));
|
|
128
|
+
return [...new Set(minimal)].sort();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function safeRead(p: string): string {
|
|
132
|
+
try {
|
|
133
|
+
return readFileSync(p, "utf8");
|
|
134
|
+
} catch {
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The `exclude` list that narrows a root build context to `dirs`: drop
|
|
140
|
+
* everything, then re-include each directory and its subtree, then drop
|
|
141
|
+
* again what no context should carry (a re-included `packages/shared`
|
|
142
|
+
* would otherwise bring its `node_modules` along). */
|
|
143
|
+
export function excludeAllBut(dirs: readonly string[]): string[] {
|
|
144
|
+
if (dirs.includes(".")) return [];
|
|
145
|
+
return ["**", ...dirs.flatMap((d) => [`!${d}`, `!${d}/**`]), ...REEXCLUDE];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Re-excluded after the re-includes: the names the default ignore drops. */
|
|
149
|
+
const REEXCLUDE = ["**/node_modules/**", "**/.git/**", "**/.spectest/**", "**/__pycache__/**", "**/.venv/**"];
|
|
150
|
+
|
|
151
|
+
// Keep `isAbsolute` referenced for callers that pass absolute fnRelPath by
|
|
152
|
+
// mistake: the walk resolves it against the root either way.
|
|
153
|
+
void isAbsolute;
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
|
|
52
52
|
import { createHmac } from "node:crypto";
|
|
53
53
|
import { existsSync, readFileSync } from "node:fs";
|
|
54
|
+
import { excludeAllBut, functionsReach } from "./supabase-functions-reach.js";
|
|
54
55
|
import { readdir, stat } from "node:fs/promises";
|
|
55
56
|
import { dirname, join } from "node:path";
|
|
56
57
|
|
|
@@ -166,6 +167,9 @@ function providerCredentials(
|
|
|
166
167
|
* cache rides into the image as a layer — so at run time the worker resolves
|
|
167
168
|
* those URLs locally and never reaches for the network.
|
|
168
169
|
*
|
|
170
|
+
* The context is the module graph the functions can load (the functions
|
|
171
|
+
* directory and what its imports lead to, `functionsReach`), copied whole
|
|
172
|
+
* so a monorepo function's `../../packages/…` resolves at bundle time too.
|
|
169
173
|
* One synthetic entrypoint imports every function, so the whole set is one
|
|
170
174
|
* graph rather than one per function. Bundling is best-effort: a function that
|
|
171
175
|
* fails to bundle (a type error, an import that only resolves at run time)
|
|
@@ -175,13 +179,13 @@ function providerCredentials(
|
|
|
175
179
|
function functionsDockerfile(fnRelPath: string): string {
|
|
176
180
|
return `FROM ${IMAGES.functions}
|
|
177
181
|
ENV DENO_DIR=${DENO_DIR}
|
|
178
|
-
COPY
|
|
182
|
+
COPY . /warm/repo
|
|
179
183
|
RUN set -eu; \
|
|
180
184
|
: > /warm/all.ts; \
|
|
181
|
-
for d in /warm/
|
|
185
|
+
for d in /warm/repo/${fnRelPath}/*/; do \
|
|
182
186
|
[ -f "$d/index.ts" ] || continue; \
|
|
183
187
|
n=$(basename "$d"); \
|
|
184
|
-
printf 'import "./
|
|
188
|
+
printf 'import "./repo/${fnRelPath}/%s/index.ts";\\n' "$n" >> /warm/all.ts; \
|
|
185
189
|
done; \
|
|
186
190
|
edge-runtime bundle --entrypoint /warm/all.ts --output /tmp/warm.eszip --quiet || true; \
|
|
187
191
|
rm -rf /tmp/warm.eszip /warm
|
|
@@ -1684,6 +1688,10 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1684
1688
|
? `${REPO_MOUNT}/${fnDir.slice(root.length + 1)}`
|
|
1685
1689
|
: `${REPO_MOUNT}/supabase/functions`;
|
|
1686
1690
|
const migrationsPath = inDir("migrations");
|
|
1691
|
+
// The directories the functions can load modules from: the functions
|
|
1692
|
+
// directory and what its imports lead to (`supabase-functions-reach.ts`).
|
|
1693
|
+
// The build context and the reach are narrowed to exactly these.
|
|
1694
|
+
const fnReachDirs = withFunctions ? functionsReach(root, fnRelPath) : [];
|
|
1687
1695
|
|
|
1688
1696
|
// ── config file (config.toml / config.json) ───────────────────────────
|
|
1689
1697
|
// The CLI's own project config, mapped onto the stack. Read at build time:
|
|
@@ -1890,8 +1898,20 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1890
1898
|
const parts: ServicesMap = {};
|
|
1891
1899
|
|
|
1892
1900
|
// ── db ──────────────────────────────────────────────────────────
|
|
1901
|
+
// What the stack reads from the project: the Supabase folder
|
|
1902
|
+
// (config.toml, migrations, seed, functions) and the mail templates,
|
|
1903
|
+
// which may sit anywhere in the repo. Declared as inputs so a change
|
|
1904
|
+
// to any of them is never taken as one that can affect no test; the
|
|
1905
|
+
// migrations volume carries the folder too, but only when there is
|
|
1906
|
+
// a migrations directory, and a config-only project must count as
|
|
1907
|
+
// well.
|
|
1908
|
+
const inputs: string[] = [
|
|
1909
|
+
...(opts.dir !== undefined && !opts.dir.startsWith("/") ? [opts.dir] : []),
|
|
1910
|
+
...templateBodies.map(([, spec]) => spec.path).filter((t) => !t.startsWith("/")),
|
|
1911
|
+
];
|
|
1893
1912
|
const dbDef: ServiceDefinition = {
|
|
1894
1913
|
image: { type: "registry", reference: opts.db?.image ?? IMAGES.db },
|
|
1914
|
+
...(inputs.length > 0 ? { inputs } : {}),
|
|
1895
1915
|
// `args` (not `command`): keep the image's docker-entrypoint.sh so it
|
|
1896
1916
|
// runs initdb + its baked init-scripts before starting.
|
|
1897
1917
|
args: [
|
|
@@ -2211,6 +2231,12 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
2211
2231
|
image: {
|
|
2212
2232
|
type: "dockerfile",
|
|
2213
2233
|
content: functionsDockerfile(fnRelPath),
|
|
2234
|
+
// The root, narrowed to the module graph: only the functions
|
|
2235
|
+
// directory and what its imports lead to enter the context, so
|
|
2236
|
+
// a change anywhere else neither uploads nor counts as reaching
|
|
2237
|
+
// the functions.
|
|
2238
|
+
context: ".",
|
|
2239
|
+
exclude: excludeAllBut(fnReachDirs),
|
|
2214
2240
|
},
|
|
2215
2241
|
// The **project root**, mounted with the functions directory in its
|
|
2216
2242
|
// real position underneath. Mounting only `supabase/functions`
|
|
@@ -2219,8 +2245,10 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
2219
2245
|
// (`../../../../packages/…`), and that only resolves if the
|
|
2220
2246
|
// directory sits where it sits in the repo. Read-only: the runtime
|
|
2221
2247
|
// only reads sources, and a function writing into its own directory
|
|
2222
|
-
// would be writing into the user's checkout.
|
|
2223
|
-
|
|
2248
|
+
// would be writing into the user's checkout. For the reach the
|
|
2249
|
+
// mount is exempt: what the functions can load is the module graph
|
|
2250
|
+
// above, which the build context already states.
|
|
2251
|
+
volumes: [{ source: projectRootPath(), target: REPO_MOUNT, readOnly: true, reachExempt: true }],
|
|
2224
2252
|
// Our router (see FUNCTIONS_MAIN_TS) sits outside that mount, so
|
|
2225
2253
|
// the project never has to carry a `main/` function of ours.
|
|
2226
2254
|
files: [{ path: "/home/deno/main/index.ts", content: FUNCTIONS_MAIN_TS }],
|