mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-07-15 22:47:09 +00:00
217 lines
6.4 KiB
TypeScript
217 lines
6.4 KiB
TypeScript
import { execFile, spawn } from 'node:child_process';
|
|
import * as fs from 'node:fs/promises';
|
|
import * as path from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
import { getFreePort } from './harness/ports';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const WEBUI_DIR = path.resolve(__dirname, '..');
|
|
const CACHE_DIR = path.join(WEBUI_DIR, 'e2e', '.cache');
|
|
const DIST_MARKER = path.join(CACHE_DIR, 'webui-dist-marker.json');
|
|
const BACKREST_BIN = path.join(CACHE_DIR, 'backrest');
|
|
const RESTIC_DATA_DIR = path.join(CACHE_DIR, 'restic-data');
|
|
|
|
export default async function globalSetup() {
|
|
await fs.mkdir(CACHE_DIR, { recursive: true });
|
|
|
|
await buildWebuiDist();
|
|
|
|
// The backrest binary embeds webui/dist (//go:embed in webuinix.go), so this
|
|
// must come after the dist build.
|
|
const binPath = await buildBackrest();
|
|
process.env.E2E_BACKREST_BIN = binPath;
|
|
|
|
// Worker processes inherit process.env set here.
|
|
await provisionRestic(binPath);
|
|
}
|
|
|
|
/**
|
|
* Builds webui/dist (embedded into the backrest binary) unless
|
|
* E2E_SKIP_WEBUI_BUILD=1 or an mtime marker says dist is already fresh.
|
|
*/
|
|
async function buildWebuiDist(): Promise<void> {
|
|
const distIndex = path.join(WEBUI_DIR, 'dist', 'index.html');
|
|
|
|
if (process.env.E2E_SKIP_WEBUI_BUILD === '1') {
|
|
if (!(await exists(distIndex))) {
|
|
throw new Error(
|
|
'E2E_SKIP_WEBUI_BUILD=1 but webui/dist does not exist; run `pnpm run build` once first',
|
|
);
|
|
}
|
|
console.log('[e2e setup] E2E_SKIP_WEBUI_BUILD=1, skipping webui build');
|
|
return;
|
|
}
|
|
|
|
const newestMtimeMs = await newestSourceMtime();
|
|
const marker = await readJson<{ newestMtimeMs: number }>(DIST_MARKER);
|
|
if (marker?.newestMtimeMs === newestMtimeMs && (await exists(distIndex))) {
|
|
console.log('[e2e setup] webui dist is fresh, skipping build');
|
|
return;
|
|
}
|
|
|
|
console.log('[e2e setup] building webui dist (pnpm run build)...');
|
|
await execFileAsync('pnpm', ['run', 'build'], {
|
|
cwd: WEBUI_DIR,
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
// Recompute: the build may itself touch tracked inputs (e.g. src/paraglide).
|
|
await fs.writeFile(DIST_MARKER, JSON.stringify({ newestMtimeMs: await newestSourceMtime() }));
|
|
}
|
|
|
|
/** Newest mtime across the inputs that feed `pnpm run build`. */
|
|
async function newestSourceMtime(): Promise<number> {
|
|
const roots = [
|
|
'src',
|
|
'gen',
|
|
'assets',
|
|
'messages',
|
|
'index.html',
|
|
'vite.config.ts',
|
|
'package.json',
|
|
].map((p) => path.join(WEBUI_DIR, p));
|
|
|
|
let newest = 0;
|
|
const walk = async (p: string): Promise<void> => {
|
|
let stat;
|
|
try {
|
|
stat = await fs.stat(p);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (stat.isDirectory()) {
|
|
const entries = await fs.readdir(p);
|
|
await Promise.all(entries.map((e) => walk(path.join(p, e))));
|
|
} else if (stat.mtimeMs > newest) {
|
|
newest = stat.mtimeMs;
|
|
}
|
|
};
|
|
await Promise.all(roots.map(walk));
|
|
return newest;
|
|
}
|
|
|
|
/** Builds the backrest binary, or uses $E2E_BACKREST_BIN if provided. */
|
|
async function buildBackrest(): Promise<string> {
|
|
if (process.env.E2E_BACKREST_BIN) {
|
|
const provided = path.resolve(WEBUI_DIR, process.env.E2E_BACKREST_BIN);
|
|
if (!(await exists(provided))) {
|
|
throw new Error(`E2E_BACKREST_BIN=${provided} does not exist`);
|
|
}
|
|
console.log(`[e2e setup] using provided backrest binary: ${provided}`);
|
|
return provided;
|
|
}
|
|
|
|
console.log('[e2e setup] building backrest binary (go build)...');
|
|
await execFileAsync('go', ['build', '-o', BACKREST_BIN, '../cmd/backrest'], {
|
|
cwd: WEBUI_DIR,
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
return BACKREST_BIN;
|
|
}
|
|
|
|
/**
|
|
* Ensures a restic binary is available and exported as
|
|
* BACKREST_RESTIC_COMMAND so per-test instances never download restic
|
|
* themselves. If not already provided (env var or cached from a previous
|
|
* run), boots a throwaway backrest instance: on startup backrest downloads
|
|
* the pinned restic version into its data dir.
|
|
*/
|
|
async function provisionRestic(binPath: string): Promise<void> {
|
|
if (process.env.BACKREST_RESTIC_COMMAND) {
|
|
console.log(`[e2e setup] using BACKREST_RESTIC_COMMAND=${process.env.BACKREST_RESTIC_COMMAND}`);
|
|
return;
|
|
}
|
|
|
|
const resticPath = path.join(RESTIC_DATA_DIR, 'restic');
|
|
if (await isExecutable(resticPath)) {
|
|
process.env.BACKREST_RESTIC_COMMAND = resticPath;
|
|
console.log(`[e2e setup] using cached restic: ${resticPath}`);
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
'[e2e setup] provisioning restic via a throwaway backrest instance (downloads on first run)...',
|
|
);
|
|
await fs.mkdir(RESTIC_DATA_DIR, { recursive: true });
|
|
const port = await getFreePort();
|
|
const logs: string[] = [];
|
|
const proc = spawn(
|
|
binPath,
|
|
[
|
|
'-data-dir',
|
|
RESTIC_DATA_DIR,
|
|
'-config-file',
|
|
path.join(RESTIC_DATA_DIR, 'config.json'),
|
|
'-bind-address',
|
|
`127.0.0.1:${port}`,
|
|
],
|
|
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
);
|
|
proc.stdout!.on('data', (d) => logs.push(d.toString()));
|
|
proc.stderr!.on('data', (d) => logs.push(d.toString()));
|
|
let exited = false;
|
|
proc.once('exit', () => {
|
|
exited = true;
|
|
});
|
|
|
|
try {
|
|
const deadline = Date.now() + 240_000;
|
|
while (!(await isExecutable(resticPath))) {
|
|
if (exited) {
|
|
throw new Error(
|
|
`throwaway backrest exited before installing restic\n--- logs ---\n${logs.join('')}`,
|
|
);
|
|
}
|
|
if (Date.now() > deadline) {
|
|
throw new Error(
|
|
`timed out waiting for restic to be installed at ${resticPath}\n--- logs ---\n${logs.join('')}`,
|
|
);
|
|
}
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
} finally {
|
|
if (!exited) {
|
|
proc.kill('SIGTERM');
|
|
await new Promise<void>((resolve) => {
|
|
const t = setTimeout(() => {
|
|
proc.kill('SIGKILL');
|
|
resolve();
|
|
}, 5_000);
|
|
proc.once('exit', () => {
|
|
clearTimeout(t);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
process.env.BACKREST_RESTIC_COMMAND = resticPath;
|
|
console.log(`[e2e setup] restic installed at ${resticPath}`);
|
|
}
|
|
|
|
async function exists(p: string): Promise<boolean> {
|
|
try {
|
|
await fs.access(p);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function isExecutable(p: string): Promise<boolean> {
|
|
try {
|
|
await fs.access(p, (await import('node:fs')).constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readJson<T>(p: string): Promise<T | undefined> {
|
|
try {
|
|
return JSON.parse(await fs.readFile(p, 'utf8')) as T;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|