mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Add PR preview instances on Codespaces (no-changelog) (#37695)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
64de0e71db
commit
fd105a6dc2
32
.devcontainer/preview/Dockerfile
Normal file
32
.devcontainer/preview/Dockerfile
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
ARG NODE_VERSION=24
|
||||
|
||||
# Debian to match the agent image's toolchain. No Playwright, no agent CLIs and no
|
||||
# Docker here: a preview only builds the repo and serves it.
|
||||
FROM node:${NODE_VERSION}-bookworm
|
||||
|
||||
# tmux holds the backend between `preview refresh` runs. The rest is for triage.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tmux sudo less jq \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Shared with the agent config, so there is one copy of each shim. The build context
|
||||
# is .devcontainer/, set in devcontainer.json, which is why these paths start with
|
||||
# codespaces/. git and gh must survive Codespaces' token rotation; the stock helpers
|
||||
# read a snapshot of $GITHUB_TOKEN and go stale in ssh and tmux shells.
|
||||
COPY codespaces/codespaces-env.sh /usr/local/lib/codespaces-env.sh
|
||||
COPY codespaces/gitcredential-refresh.sh /usr/local/bin/gitcredential-refresh.sh
|
||||
COPY codespaces/gh-shim.sh /usr/local/bin/gh
|
||||
RUN chmod +x /usr/local/bin/gitcredential-refresh.sh /usr/local/bin/gh
|
||||
|
||||
# Interactive shells for a human debugging the box.
|
||||
RUN echo '. /usr/local/lib/codespaces-env.sh' >> /etc/bash.bashrc
|
||||
|
||||
RUN echo node ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/node && chmod 0440 /etc/sudoers.d/node
|
||||
RUN mkdir -p /workspaces && chown node:node /workspaces
|
||||
RUN corepack enable
|
||||
|
||||
ENV PNPM_HOME=/home/node/.local/share/pnpm
|
||||
ENV PATH=$PNPM_HOME/bin:$PATH
|
||||
|
||||
USER node
|
||||
RUN mkdir -p $PNPM_HOME ~/.pnpm-store && pnpm config set store-dir ~/.pnpm-store --global
|
||||
37
.devcontainer/preview/devcontainer.json
Normal file
37
.devcontainer/preview/devcontainer.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "n8n (PR preview)",
|
||||
// No compose: a preview needs no Postgres sidecar. n8n defaults to sqlite
|
||||
// (packages/@n8n/config/src/configs/database.config.ts), which is what a
|
||||
// throwaway instance wants. Context is .devcontainer/ so the Dockerfile can
|
||||
// reuse the token-rotation shims in codespaces/ instead of copying them.
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": ".."
|
||||
},
|
||||
// Codespaces clones the repo into /workspaces/<repo>
|
||||
"workspaceFolder": "/workspaces/n8n",
|
||||
// The floor, so the 2-core machine stays available. This config exists mainly to
|
||||
// ask for less than the agent one: the measured footprint is ~15gb, the
|
||||
// editor-ui build peaks near 3gb, and scripts/agent-setup.mjs already builds the
|
||||
// whole repo inside 6gb.
|
||||
"hostRequirements": {
|
||||
"cpus": 2,
|
||||
"memory": "8gb",
|
||||
"storage": "32gb"
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
// gh codespace ssh needs an SSH server inside the container
|
||||
"ghcr.io/devcontainers/features/sshd:1": {}
|
||||
},
|
||||
"forwardPorts": [5678],
|
||||
// Runs in prebuilds, so a fresh preview starts with deps installed and the build
|
||||
// warm. Concurrency and heap are capped for the 2-core box, matching the
|
||||
// reasoning in scripts/agent-setup.mjs. A failing onCreateCommand is fatal —
|
||||
// Codespaces discards the container — so the build only ever tails its log.
|
||||
"onCreateCommand": "corepack install && pnpm install && (NODE_OPTIONS=--max-old-space-size=6144 pnpm exec turbo run build --concurrency=2 > /tmp/build.log 2>&1 || tail -n 50 /tmp/build.log)",
|
||||
// Codespaces regenerates the managed /etc/gitconfig on each start, and the ssh
|
||||
// command shells `pnpm preview` uses are neither login nor interactive, so
|
||||
// profile.d and bashrc never fire for them. Register the helper here instead.
|
||||
"postStartCommand": "git config --global --get-all credential.helper 2>/dev/null | grep -qs gitcredential-refresh || git config --global --add credential.helper /usr/local/bin/gitcredential-refresh.sh"
|
||||
}
|
||||
81
.devcontainer/preview/preview-signin-hook.cjs
Normal file
81
.devcontainer/preview/preview-signin-hook.cjs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* One-click sign-in for preview instances. Loaded through EXTERNAL_HOOK_FILES by
|
||||
* scripts/preview-serve.mjs, so it is never part of a packaged n8n.
|
||||
*
|
||||
* The route performs an ordinary login on the visitor's behalf: it calls
|
||||
* /rest/login with the preview instance's own seeded credentials and forwards the
|
||||
* session cookie that endpoint returns. The visitor gets exactly the session a
|
||||
* normal sign-in would give them, so this is a convenience over the login form
|
||||
* rather than a different way in.
|
||||
*
|
||||
* Only enable this on a throwaway instance whose port is org-visible. It refuses to
|
||||
* register unless N8N_PREVIEW_SIGNIN is exactly "1", so loading the file by
|
||||
* accident does nothing.
|
||||
*
|
||||
* Calling /rest/login rather than AuthService keeps this working across changes to
|
||||
* n8n's internals, and means it can never grant more than a real login would.
|
||||
*
|
||||
* Two things make the route reachable:
|
||||
* - `n8n.ready` fires after configure(), so the SPA catch-all is already
|
||||
* registered. N8N_ADDITIONAL_NON_UI_ROUTES must list this path, or
|
||||
* historyApiHandler answers with index.html before the route is reached.
|
||||
* - the handler is registered on server.app, which AbstractServer exposes.
|
||||
*/
|
||||
const ROUTE = '/preview-signin';
|
||||
|
||||
module.exports = {
|
||||
n8n: {
|
||||
ready: [
|
||||
async function previewSignin(server) {
|
||||
if (process.env.N8N_PREVIEW_SIGNIN !== '1') return;
|
||||
|
||||
const email = process.env.PREVIEW_OWNER_EMAIL;
|
||||
const password = process.env.PREVIEW_OWNER_PASSWORD;
|
||||
if (!email || !password) {
|
||||
console.warn(
|
||||
`[preview-signin] PREVIEW_OWNER_EMAIL or PREVIEW_OWNER_PASSWORD is unset — ${ROUTE} not registered`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const port = process.env.N8N_PORT ?? '5678';
|
||||
|
||||
server.app.get(ROUTE, async (_req, res) => {
|
||||
const fallback = `Sign in as ${email} instead.`;
|
||||
try {
|
||||
const login = await fetch(`http://127.0.0.1:${port}/rest/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ emailOrLdapLoginId: email, password }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!login.ok) {
|
||||
res
|
||||
.status(502)
|
||||
.type('text')
|
||||
.send(`Preview sign-in failed: /rest/login returned ${login.status}. ${fallback}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hand the browser exactly the cookies the login endpoint issued.
|
||||
const cookies = login.headers.getSetCookie();
|
||||
if (cookies.length === 0) {
|
||||
res
|
||||
.status(502)
|
||||
.type('text')
|
||||
.send(`Preview sign-in failed: no session cookie was issued. ${fallback}`);
|
||||
return;
|
||||
}
|
||||
res.setHeader('set-cookie', cookies);
|
||||
res.redirect('/');
|
||||
} catch (error) {
|
||||
res.status(502).type('text').send(`Preview sign-in failed: ${error.message}. ${fallback}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[preview-signin] ${ROUTE} is live — it signs the visitor in as ${email}`);
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
2
.github/scripts/package.json
vendored
2
.github/scripts/package.json
vendored
|
|
@ -6,7 +6,7 @@
|
|||
},
|
||||
"packageManager": "pnpm@11.22.0",
|
||||
"scripts": {
|
||||
"test": "node --test --experimental-test-module-mocks ./*.test.mjs ./docker/*.test.mjs ./owners/*.test.mjs ./quality/*.test.mjs ./slack/*.test.mjs ./stale/*.test.mjs ../../.devcontainer/codespaces/*.test.mjs ../../scripts/licenses/*.test.mjs ../../scripts/mutation-health/*.test.mjs",
|
||||
"test": "node --test --experimental-test-module-mocks ./*.test.mjs ./docker/*.test.mjs ./owners/*.test.mjs ./quality/*.test.mjs ./slack/*.test.mjs ./stale/*.test.mjs ../../scripts/*.test.mjs ../../scripts/licenses/*.test.mjs ../../scripts/mutation-health/*.test.mjs ../../.devcontainer/codespaces/*.test.mjs",
|
||||
"generate-sbom": "FETCH_LICENSE=true cdxgen -t pnpm --no-install-deps --profile license-compliance --spec-version 1.6 -o ../../sbom-source.cdx.json ../../compiled/",
|
||||
"enrich-sbom": "node ../../scripts/licenses/enrich-sbom.mjs ../../sbom-source.cdx.json",
|
||||
"render-licenses-md": "node ../../scripts/licenses/render-licenses-md.mjs ../../sbom-source.cdx.json ../../packages/cli/THIRD_PARTY_LICENSES.md ../../compiled/node_modules",
|
||||
|
|
|
|||
7
.github/workflows/ci-pull-requests.yml
vendored
7
.github/workflows/ci-pull-requests.yml
vendored
|
|
@ -74,6 +74,8 @@ jobs:
|
|||
!scripts/agent-setup.mjs
|
||||
!scripts/backend-module/**
|
||||
!scripts/cloud-session.mjs
|
||||
!scripts/preview.mjs
|
||||
!scripts/preview-serve.mjs
|
||||
!scripts/licenses/**
|
||||
!scripts/mutation-health/**
|
||||
!scripts/sync-agent-skill-links.mjs
|
||||
|
|
@ -89,6 +91,8 @@ jobs:
|
|||
!scripts/agent-setup.mjs
|
||||
!scripts/backend-module/**
|
||||
!scripts/cloud-session.mjs
|
||||
!scripts/preview.mjs
|
||||
!scripts/preview-serve.mjs
|
||||
!scripts/licenses/**
|
||||
!scripts/mutation-health/**
|
||||
!scripts/sync-agent-skill-links.mjs
|
||||
|
|
@ -116,6 +120,9 @@ jobs:
|
|||
.github/scripts/**
|
||||
.devcontainer/codespaces/agent-worker*.mjs
|
||||
OWNERS
|
||||
scripts/*.test.mjs
|
||||
scripts/codespace-env.mjs
|
||||
scripts/serve-ready.mjs
|
||||
scripts/licenses/**
|
||||
scripts/mutation-health/**
|
||||
cubic.yaml
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@
|
|||
"dev-metrics:reset": "node scripts/dev-metrics/setup.mjs --reset",
|
||||
"clean": "turbo run clean",
|
||||
"session": "node scripts/cloud-session.mjs",
|
||||
"preview": "node scripts/preview.mjs",
|
||||
"preview:serve": "node scripts/preview-serve.mjs",
|
||||
"session:opencode": "node scripts/cloud-session.mjs --opencode",
|
||||
"session:shell": "node scripts/cloud-session.mjs --shell",
|
||||
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
|
||||
|
|
|
|||
|
|
@ -8,18 +8,22 @@
|
|||
// dev:be serves the editor from the `dist` build. A frontend change appears only
|
||||
// after `--build` and this restart. For live frontend hot reload, use
|
||||
// `pnpm dev:fe:editor` on 8080. That path needs `pnpm session tunnel 5678 8080`.
|
||||
//
|
||||
// For a PR preview instead of your own session, use `pnpm preview up <pr>`.
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { existsSync, openSync } from 'node:fs';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import { codespaceName, forwardingDomain } from './codespace-env.mjs';
|
||||
import {
|
||||
reportUp,
|
||||
serveHealthPath,
|
||||
servePort,
|
||||
shareWithOrg,
|
||||
waitForHealth,
|
||||
} from './serve-ready.mjs';
|
||||
|
||||
const build = process.argv.includes('--build');
|
||||
const port = process.env.N8N_PORT ?? '5678';
|
||||
// Probe the same health path the backend serves (defaults to /healthz).
|
||||
const healthPath = process.env.N8N_ENDPOINT_HEALTH
|
||||
? `/${process.env.N8N_ENDPOINT_HEALTH.replace(/^\//, '')}`
|
||||
: '/healthz';
|
||||
const port = servePort();
|
||||
const healthPath = serveHealthPath();
|
||||
const LOG = '/tmp/n8n-dev-be.log';
|
||||
const BUILD_LOG = '/tmp/dev-up-build.log';
|
||||
|
||||
|
|
@ -44,53 +48,9 @@ console.log(`Starting backend (pnpm dev:be; log: ${LOG})…`);
|
|||
const fd = openSync(LOG, 'a');
|
||||
spawn('pnpm', ['dev:be'], { detached: true, stdio: ['ignore', fd, fd] }).unref();
|
||||
|
||||
const deadline = Date.now() + 120_000;
|
||||
let healthy = false;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}${healthPath}`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
healthy = true;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
await sleep(3000);
|
||||
}
|
||||
|
||||
const name = codespaceName();
|
||||
const url = name ? `https://${name}-${port}.${forwardingDomain()}` : `http://localhost:${port}`;
|
||||
if (!healthy) {
|
||||
if (!(await waitForHealth(port, healthPath))) {
|
||||
console.error(`\nBackend did not answer ${healthPath} within 2 min — check ${LOG}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// In a codespace, share the port with the org. Then any n8n member can open the
|
||||
// URL. GitHub makes every port private again at each start, so set it here. If
|
||||
// `gh` fails, print the reason and continue.
|
||||
let orgShared = false;
|
||||
let shareError;
|
||||
if (name) {
|
||||
try {
|
||||
execFileSync('gh', ['codespace', 'ports', 'visibility', `${port}:org`, '-c', name], {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
orgShared = true;
|
||||
} catch (error) {
|
||||
const stderr = error.stderr?.toString().trim();
|
||||
shareError = (stderr || error.message).split('\n').pop();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nUp: ${url}`);
|
||||
if (name)
|
||||
console.log(
|
||||
orgShared
|
||||
? '(org-visible — any n8n member signed into GitHub can open it)'
|
||||
: `(still private: ${shareError} — retry with \`gh codespace ports visibility ${port}:org -c ${name}\`)`,
|
||||
);
|
||||
else if (existsSync('/workspaces/.codespaces'))
|
||||
console.log(
|
||||
`(on a codespace but the box name did not resolve, so ${port} was not shared with the org — see .devcontainer/codespaces/README.md)`,
|
||||
);
|
||||
reportUp(port, shareWithOrg(port));
|
||||
|
|
|
|||
137
scripts/preview-serve.mjs
Normal file
137
scripts/preview-serve.mjs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env node
|
||||
// Serve the checked-out tree as a preview instance, from inside a codespace.
|
||||
// Idempotent: it stops any previous preview backend first, so `preview up` and
|
||||
// `preview refresh` share this one entry point.
|
||||
//
|
||||
// pnpm preview:serve
|
||||
//
|
||||
// dev:up detaches the backend, which leaves nothing for a refresh to stop. So
|
||||
// this owns the lifecycle instead: dev:be runs in a named tmux session.
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { openSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { serveHealthPath, servePort, waitForHealth } from './serve-ready.mjs';
|
||||
|
||||
const SESSION = 'n8n-preview';
|
||||
const BUILD_LOG = '/tmp/preview-build.log';
|
||||
const BE_LOG = '/tmp/preview-be.log';
|
||||
// The preview box has 2 cores and 8gb. Cap both, for the reason spelled out in
|
||||
// scripts/agent-setup.mjs: unbounded turbo concurrency is what exhausts a small box.
|
||||
const CONCURRENCY = process.env.PREVIEW_BUILD_CONCURRENCY ?? '2';
|
||||
// A fresh instance opens on the owner-setup wall, which is friction for a reviewer
|
||||
// who only wants to look at the UI. Running with no auth is not an option: the
|
||||
// `skipInstanceOwnerSetup` setting was removed in migration 1681134145997. So seed a
|
||||
// known owner instead. These are not secrets — the security boundary is the
|
||||
// org-visible forwarded port, which already requires a GitHub sign-in and n8n org
|
||||
// membership. Never point this at anything publicly reachable.
|
||||
// The password must satisfy passwordSchema: 8-64 chars, >=1 digit, >=1 uppercase.
|
||||
const OWNER_EMAIL = process.env.PREVIEW_OWNER_EMAIL ?? 'preview@n8n.io';
|
||||
const OWNER_PASSWORD = process.env.PREVIEW_OWNER_PASSWORD ?? 'PreviewInstance1';
|
||||
const HEAP_MB = process.env.PREVIEW_BUILD_HEAP_MB ?? '6144';
|
||||
// Resolve from this file, not cwd, so the tmux command lands in the right tree.
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const port = servePort();
|
||||
const healthPath = serveHealthPath();
|
||||
|
||||
const tmux = (...args) => spawnSync('tmux', args, { stdio: 'ignore' });
|
||||
|
||||
// Stop the old backend before rebuilding: it frees the port, and it stops the
|
||||
// previous build being served alongside newly written assets.
|
||||
tmux('kill-session', '-t', SESSION);
|
||||
|
||||
console.log(`Building (turbo cache — fast when warm; log: ${BUILD_LOG})…`);
|
||||
const buildFd = openSync(BUILD_LOG, 'w');
|
||||
try {
|
||||
execFileSync('pnpm', ['exec', 'turbo', 'run', 'build', `--concurrency=${CONCURRENCY}`], {
|
||||
cwd: repoRoot,
|
||||
stdio: ['ignore', buildFd, buildFd],
|
||||
// Appended last so it wins over an inherited value: node takes the final flag.
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: [process.env.NODE_OPTIONS, `--max-old-space-size=${HEAP_MB}`]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
console.error(`Build failed — see ${BUILD_LOG}:`);
|
||||
execFileSync('tail', ['-n', '30', BUILD_LOG], { stdio: 'inherit' });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// One-click sign-in, so a reviewer lands in the editor rather than a login form.
|
||||
// N8N_ADDITIONAL_NON_UI_ROUTES is required: `n8n.ready` runs after the SPA catch-all
|
||||
// is registered, so without it historyApiHandler answers /preview-signin with
|
||||
// index.html. dev:be runs turbo with --env-mode=loose, which passes these through.
|
||||
//
|
||||
// Set with tmux `-e` rather than interpolated into the command: tmux does not
|
||||
// inherit the caller's environment for arbitrary variables (the same trap the
|
||||
// codespaces README documents), and this keeps the values out of a shell string.
|
||||
const SIGNIN_ROUTE = 'preview-signin';
|
||||
const signinEnv = [
|
||||
`EXTERNAL_HOOK_FILES=${repoRoot}/.devcontainer/preview/preview-signin-hook.cjs`,
|
||||
`N8N_ADDITIONAL_NON_UI_ROUTES=${SIGNIN_ROUTE}`,
|
||||
'N8N_PREVIEW_SIGNIN=1',
|
||||
`PREVIEW_OWNER_EMAIL=${OWNER_EMAIL}`,
|
||||
`PREVIEW_OWNER_PASSWORD=${OWNER_PASSWORD}`,
|
||||
].flatMap((pair) => ['-e', pair]);
|
||||
|
||||
console.log(`Starting backend in tmux session '${SESSION}' (log: ${BE_LOG})…`);
|
||||
const started = tmux(
|
||||
'new',
|
||||
'-d',
|
||||
'-s',
|
||||
SESSION,
|
||||
...signinEnv,
|
||||
`cd ${repoRoot} && exec pnpm dev:be > ${BE_LOG} 2>&1`,
|
||||
);
|
||||
if (started.status !== 0) {
|
||||
console.error(`Could not start tmux session '${SESSION}'.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!(await waitForHealth(port, healthPath))) {
|
||||
console.error(`\nBackend did not answer ${healthPath} within 2 min — check ${BE_LOG}:`);
|
||||
execFileSync('tail', ['-n', '30', BE_LOG], { stdio: 'inherit' });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Promote the shell user into the owner so the instance opens on a login page with
|
||||
// known credentials, not the setup wizard. Idempotent: on a refresh the sqlite file
|
||||
// survives, the owner already exists, and the endpoint rejects the second attempt.
|
||||
async function seedOwner() {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`http://127.0.0.1:${port}/rest/owner/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: OWNER_EMAIL,
|
||||
firstName: 'Preview',
|
||||
lastName: 'User',
|
||||
password: OWNER_PASSWORD,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Could not reach the owner setup endpoint: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
console.log(`Seeded the instance owner: ${OWNER_EMAIL} / ${OWNER_PASSWORD}`);
|
||||
return;
|
||||
}
|
||||
// Already set up is the normal refresh case, not a failure.
|
||||
console.log(
|
||||
`Owner already set up (HTTP ${res.status}) — sign in as ${OWNER_EMAIL} / ${OWNER_PASSWORD}`,
|
||||
);
|
||||
}
|
||||
|
||||
await seedOwner();
|
||||
console.log(`One-click sign-in path: /${SIGNIN_ROUTE}`);
|
||||
|
||||
// The host shares the port and prints the URL: its gh holds the codespace scope.
|
||||
console.log(`Ready: the backend answers ${healthPath} on port ${port}.`);
|
||||
305
scripts/preview.mjs
Normal file
305
scripts/preview.mjs
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
#!/usr/bin/env node
|
||||
// Preview instances for pull requests: one codespace per PR, created from the
|
||||
// master prebuild, serving the PR head behind the org's GitHub auth.
|
||||
// Requires `gh` with the codespace scope (gh auth refresh -s codespace).
|
||||
//
|
||||
// pnpm preview up <pr> create or start the box, then serve the PR head
|
||||
// pnpm preview refresh <pr> move to the current PR head and restart
|
||||
// pnpm preview down <pr> delete the box
|
||||
// pnpm preview ls list preview boxes
|
||||
//
|
||||
// --json print {pr, sha, codespace, url} for a workflow to consume
|
||||
// --dry-run resolve the PR and print the commands, touching nothing
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import { shareWithOrg } from './serve-ready.mjs';
|
||||
|
||||
const REPO = 'n8n-io/n8n';
|
||||
// Must match a config that has a prebuild, or create takes ~20 min instead of a
|
||||
// couple. Prebuilds are per-devcontainer-path, so a new config is cold until one is
|
||||
// configured for it in repo settings and has run. Override to borrow another
|
||||
// config's prebuild, e.g. PREVIEW_DEVCONTAINER=.devcontainer/codespaces/devcontainer.json
|
||||
// with PREVIEW_MACHINE=premiumLinux, which is what that config's hostRequirements needs.
|
||||
const DEVCONTAINER = process.env.PREVIEW_DEVCONTAINER ?? '.devcontainer/preview/devcontainer.json';
|
||||
// The preview devcontainer asks for 2 cpus / 8gb / 32gb, which keeps the smallest
|
||||
// type available. Set PREVIEW_MACHINE=standardLinux32gb to compare against 4 cores.
|
||||
const MACHINE = process.env.PREVIEW_MACHINE ?? 'basicLinux32gb';
|
||||
const IDLE_TIMEOUT = '30m';
|
||||
const RETENTION_PERIOD = '24h';
|
||||
// Only used to build the URL printed here. The in-box script reads the codespace's
|
||||
// own N8N_PORT and prints the authoritative URL, so override both or neither.
|
||||
const PORT = process.env.N8N_PORT ?? '5678';
|
||||
const PREFIX = 'preview/pr-';
|
||||
// GitHub hands VS Code a per-codespace forwarding domain, but it is not readable
|
||||
// from here. Every box uses this one; the in-box script prints the resolved value.
|
||||
const FORWARDING_DOMAIN = 'app.github.dev';
|
||||
|
||||
// The display name is the lookup key, so a preview can never be confused with a
|
||||
// developer's own `pnpm session` box on the same repo.
|
||||
const displayNameFor = (pr) => `${PREFIX}${pr}`;
|
||||
|
||||
const ghTty = (...args) => spawnSync('gh', args, { stdio: 'inherit' });
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function ghJson(args, retry = false) {
|
||||
try {
|
||||
return JSON.parse(execFileSync('gh', args, { encoding: 'utf8' }).trim());
|
||||
} catch (error) {
|
||||
if (!retry && error.message.includes('This API operation needs the "codespace" scope')) {
|
||||
console.log('Requesting codespace scope…');
|
||||
ghTty('auth', 'refresh', '-h', 'github.com', '-s', 'codespace');
|
||||
return ghJson(args, true);
|
||||
}
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const listPreviews = () =>
|
||||
ghJson(['codespace', 'list', '-R', REPO, '--json', 'name,displayName,state,lastUsedAt']).filter(
|
||||
(cs) => cs.displayName?.startsWith(PREFIX),
|
||||
);
|
||||
|
||||
// Match on the display name. `list[0]` would take whichever box comes first,
|
||||
// including one that belongs to a developer's session.
|
||||
const findPreview = (pr) => listPreviews().find((cs) => cs.displayName === displayNameFor(pr));
|
||||
|
||||
function prHead(pr) {
|
||||
const head = ghJson([
|
||||
'pr',
|
||||
'view',
|
||||
pr,
|
||||
'-R',
|
||||
REPO,
|
||||
'--json',
|
||||
'headRefName,headRefOid,isCrossRepository,state',
|
||||
]);
|
||||
if (head.isCrossRepository)
|
||||
fail(
|
||||
`PR #${pr} comes from a fork. A codespace's token is scoped to ${REPO}, so it cannot check out a fork head.`,
|
||||
);
|
||||
if (head.state !== 'OPEN') console.log(`Note: PR #${pr} is ${head.state}.`);
|
||||
return head;
|
||||
}
|
||||
|
||||
// Check out the exact head SHA, not the branch tip: the branch can move while this
|
||||
// runs, and the preview has to match the SHA the PR comment points at.
|
||||
const serveCommand = (head) =>
|
||||
[
|
||||
'cd /workspaces/n8n',
|
||||
`git fetch origin ${head.headRefName}`,
|
||||
`git checkout --detach ${head.headRefOid}`,
|
||||
// The serve script comes from the checked-out PR head, so a PR cut before this
|
||||
// tooling landed on master does not have it. Say so, instead of letting pnpm
|
||||
// report a missing script.
|
||||
// An `||` here would also fire when an earlier step in this && chain failed,
|
||||
// reporting the wrong cause. `if` keeps the test self-contained.
|
||||
'if [ ! -f scripts/preview-serve.mjs ]; then echo "This PR predates the preview tooling. Rebase it on master and retry."; exit 1; fi',
|
||||
// Cheap when nothing changed. Skipping it is how a preview ends up running
|
||||
// against stale dependencies after a lockfile change.
|
||||
'pnpm install --frozen-lockfile',
|
||||
'pnpm preview:serve',
|
||||
].join(' && ');
|
||||
|
||||
const createArgs = (pr, head) => [
|
||||
'codespace',
|
||||
'create',
|
||||
'-R',
|
||||
REPO,
|
||||
'-b',
|
||||
head.headRefName,
|
||||
'-d',
|
||||
displayNameFor(pr),
|
||||
'--devcontainer-path',
|
||||
DEVCONTAINER,
|
||||
'-m',
|
||||
MACHINE,
|
||||
'--idle-timeout',
|
||||
IDLE_TIMEOUT,
|
||||
'--retention-period',
|
||||
RETENTION_PERIOD,
|
||||
];
|
||||
|
||||
function report(pr, head, codespace, json, orgVisible = false) {
|
||||
const url = `https://${codespace}-${PORT}.${FORWARDING_DOMAIN}`;
|
||||
if (json)
|
||||
console.log(
|
||||
JSON.stringify({ pr: Number(pr), sha: head.headRefOid, codespace, url, orgVisible }),
|
||||
);
|
||||
else console.log(`\nPreview for PR #${pr}: ${url}${orgVisible ? ' (org-visible)' : ''}`);
|
||||
}
|
||||
|
||||
// `gh codespace create` returns when the record exists, not when the box can accept
|
||||
// a connection, and sshd lags the Available state by a few seconds more. Probe with a
|
||||
// no-op so a connection problem never looks like a failed build.
|
||||
async function waitForSsh(name, timeoutMs = 600_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let attempt = 0;
|
||||
let lastError = 'no attempt completed';
|
||||
while (Date.now() < deadline) {
|
||||
const probe = spawnSync('gh', ['codespace', 'ssh', '-c', name, '--', 'true'], {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (probe.status === 0) return;
|
||||
lastError = (probe.stderr ?? '').trim().split('\n').pop() || `exit ${probe.status}`;
|
||||
if (attempt++ === 0) console.log(`Waiting for ${name} to accept ssh…`);
|
||||
await sleep(5000);
|
||||
}
|
||||
fail(
|
||||
`${name} did not accept ssh within ${Math.round(timeoutMs / 1000)}s — last error: ${lastError}`,
|
||||
);
|
||||
}
|
||||
|
||||
// `waitForHealth` polls 127.0.0.1 inside the box, which goes green before the
|
||||
// Codespaces agent notices the listener and registers the forward. Sharing a port
|
||||
// that is not forwarded yet fails, so keep trying until it is.
|
||||
async function shareWhenForwarded(port, name, timeoutMs = 120_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let share = shareWithOrg(port, name);
|
||||
let waited = false;
|
||||
while (!share.shared && Date.now() < deadline) {
|
||||
if (!waited) {
|
||||
console.log(`Waiting for port ${port} to be forwarded before sharing it…`);
|
||||
waited = true;
|
||||
}
|
||||
await sleep(3000);
|
||||
share = shareWithOrg(port, name);
|
||||
}
|
||||
return share;
|
||||
}
|
||||
|
||||
async function serve(pr, cs, head, { json, dryRun }) {
|
||||
const command = serveCommand(head);
|
||||
if (dryRun) {
|
||||
console.log(`Would ssh to ${cs.name} and run:\n ${command}`);
|
||||
console.log(`Would then share port ${PORT} with the org.`);
|
||||
report(pr, head, cs.name, json);
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForSsh(cs.name);
|
||||
|
||||
console.log(`Serving ${head.headRefOid.slice(0, 7)} on ${cs.name}…`);
|
||||
const { status } = ghTty('codespace', 'ssh', '-c', cs.name, '--', command);
|
||||
if (status !== 0) fail('Serving failed — see the output above.');
|
||||
|
||||
// Share from here rather than inside the box: this gh already held the codespace
|
||||
// scope to create it, while the codespace's own token is only repo-scoped.
|
||||
// GitHub also makes every forwarded port private again at each container start.
|
||||
const share = await shareWhenForwarded(PORT, cs.name);
|
||||
if (!share.shared)
|
||||
console.error(
|
||||
`Port ${PORT} is still private: ${share.error} — retry with \`gh codespace ports visibility ${PORT}:org -c ${cs.name}\``,
|
||||
);
|
||||
report(pr, head, cs.name, json, share.shared);
|
||||
}
|
||||
|
||||
async function up(pr, options) {
|
||||
const head = prHead(pr);
|
||||
let cs = findPreview(pr);
|
||||
|
||||
if (!cs) {
|
||||
if (options.dryRun) {
|
||||
console.log(`Would create a box for PR #${pr} (${head.headRefName}):`);
|
||||
console.log(` gh ${createArgs(pr, head).join(' ')}`);
|
||||
console.log(`Then ssh to it and run:\n ${serveCommand(head)}`);
|
||||
// No box yet, so there is no name and no URL to report.
|
||||
console.log('The URL is only known once the box exists.');
|
||||
return;
|
||||
}
|
||||
console.log(`Creating a preview box for PR #${pr} (${head.headRefName})…`);
|
||||
// Run interactive: the devcontainer asks for access to the private skills
|
||||
// repo, so gh prints an authorization URL and waits for an answer.
|
||||
const { status } = ghTty(...createArgs(pr, head));
|
||||
if (status !== 0)
|
||||
fail('Codespace create failed. Authorize the permissions prompt above, then retry.');
|
||||
cs = findPreview(pr);
|
||||
if (!cs) fail(`Created a box for PR #${pr} but could not find it — run \`pnpm preview ls\`.`);
|
||||
} else if (cs.state !== 'Available') {
|
||||
// There is no `gh codespace start`; ssh starts a stopped box.
|
||||
console.log(`${cs.name} is ${cs.state} — ssh will start it (~30-60 s)…`);
|
||||
}
|
||||
|
||||
await serve(pr, cs, head, options);
|
||||
}
|
||||
|
||||
async function refresh(pr, options) {
|
||||
const cs = findPreview(pr);
|
||||
if (!cs) fail(`No preview box for PR #${pr}. Run \`pnpm preview up ${pr}\` first.`);
|
||||
await serve(pr, cs, prHead(pr), options);
|
||||
}
|
||||
|
||||
function down(pr, { dryRun }) {
|
||||
const cs = findPreview(pr);
|
||||
if (!cs) {
|
||||
console.log(`No preview box for PR #${pr}.`);
|
||||
return;
|
||||
}
|
||||
if (dryRun) {
|
||||
console.log(`Would delete ${cs.name} (PR #${pr}).`);
|
||||
return;
|
||||
}
|
||||
const { status } = ghTty('codespace', 'delete', '-c', cs.name, '--force');
|
||||
if (status !== 0) process.exit(status ?? 1);
|
||||
console.log(`Deleted ${cs.name} (PR #${pr})`);
|
||||
}
|
||||
|
||||
function ls() {
|
||||
const previews = listPreviews();
|
||||
if (!previews.length) {
|
||||
console.log(`No preview boxes on ${REPO}.`);
|
||||
return;
|
||||
}
|
||||
for (const cs of previews)
|
||||
console.log(`${cs.displayName}\t${cs.state}\t${cs.name}\tlast used ${cs.lastUsedAt}`);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const options = { json: args.includes('--json'), dryRun: args.includes('--dry-run') };
|
||||
const [cmd, pr] = args.filter((arg) => !arg.startsWith('--'));
|
||||
|
||||
/**
|
||||
* Grabs the given PR number from command or tries to default to
|
||||
* the PR number of the branch currently checked out.
|
||||
* */
|
||||
async function requirePr() {
|
||||
const noPrSpecified = !/^\d+$/.test(pr ?? '')
|
||||
|
||||
if (pr && noPrSpecified) {
|
||||
fail(`Invalid PR number format. Usage example: \`pnpm preview ${cmd} 1234\``)
|
||||
}
|
||||
|
||||
if (noPrSpecified) {
|
||||
const prInfoForCurrentBranch = ghJson(['pr', 'view', "--json", "number"]);
|
||||
if (prInfoForCurrentBranch && Number.isInteger(prInfoForCurrentBranch.number)) {
|
||||
return prInfoForCurrentBranch.number;
|
||||
}
|
||||
}
|
||||
|
||||
if (noPrSpecified) {
|
||||
fail(`Give a PR number, e.g. \`pnpm preview ${cmd} 1234\` or check out a branch that has an open PR and run \`pnpm preview ${cmd}\`.`)
|
||||
}
|
||||
return pr;
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 'up':
|
||||
await up(await requirePr(), options);
|
||||
break;
|
||||
case 'refresh':
|
||||
await refresh(await requirePr(), options);
|
||||
break;
|
||||
case 'down':
|
||||
down(await requirePr(), options);
|
||||
break;
|
||||
case 'ls':
|
||||
ls();
|
||||
break;
|
||||
default:
|
||||
fail('Usage: pnpm preview <up|refresh|down|ls> [pr] [--json] [--dry-run]');
|
||||
}
|
||||
65
scripts/serve-ready.mjs
Normal file
65
scripts/serve-ready.mjs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Shared serve plumbing for `dev:up` and `preview:serve`: wait for the backend to
|
||||
// answer its health endpoint, share the port with the org when we are on a
|
||||
// codespace, and print the URL.
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import { codespaceName, forwardingDomain } from './codespace-env.mjs';
|
||||
|
||||
const CODESPACES_DIR = '/workspaces/.codespaces';
|
||||
|
||||
export const servePort = () => process.env.N8N_PORT ?? '5678';
|
||||
|
||||
// Probe the same health path the backend serves (defaults to /healthz).
|
||||
export const serveHealthPath = () =>
|
||||
process.env.N8N_ENDPOINT_HEALTH
|
||||
? `/${process.env.N8N_ENDPOINT_HEALTH.replace(/^\//, '')}`
|
||||
: '/healthz';
|
||||
|
||||
export async function waitForHealth(port, healthPath, timeoutMs = 120_000, intervalMs = 3000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}${healthPath}`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) return true;
|
||||
} catch {}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// In a codespace, share the port with the org. Then any n8n member can open the
|
||||
// URL. GitHub makes every port private again at each start, so set it here. If
|
||||
// `gh` fails, report the reason and let the caller carry on.
|
||||
export function shareWithOrg(port, name = codespaceName()) {
|
||||
if (!name) return { shared: false };
|
||||
try {
|
||||
execFileSync('gh', ['codespace', 'ports', 'visibility', `${port}:org`, '-c', name], {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
return { name, shared: true };
|
||||
} catch (error) {
|
||||
const stderr = error.stderr?.toString().trim();
|
||||
return { name, shared: false, error: (stderr || error.message).split('\n').pop() };
|
||||
}
|
||||
}
|
||||
|
||||
export const serveUrl = (port, name) =>
|
||||
name ? `https://${name}-${port}.${forwardingDomain()}` : `http://localhost:${port}`;
|
||||
|
||||
export function reportUp(port, { name, shared, error }, codespacesDir = CODESPACES_DIR) {
|
||||
console.log(`\nUp: ${serveUrl(port, name)}`);
|
||||
if (name)
|
||||
console.log(
|
||||
shared
|
||||
? '(org-visible — any n8n member signed into GitHub can open it)'
|
||||
: `(still private: ${error} — retry with \`gh codespace ports visibility ${port}:org -c ${name}\`)`,
|
||||
);
|
||||
else if (existsSync(codespacesDir))
|
||||
console.log(
|
||||
`(on a codespace but the box name did not resolve, so ${port} was not shared with the org — see .devcontainer/codespaces/README.md)`,
|
||||
);
|
||||
}
|
||||
143
scripts/serve-ready.test.mjs
Normal file
143
scripts/serve-ready.test.mjs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// node --test scripts/serve-ready.test.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { after, afterEach, describe, it } from 'node:test';
|
||||
|
||||
import { reportUp, serveHealthPath, servePort, serveUrl, waitForHealth } from './serve-ready.mjs';
|
||||
|
||||
const NAME = 'psychic-umbrella-wqj9pvw9p939vp6';
|
||||
const DOMAIN = 'app.github.dev';
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
after(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
/** Serves `status` on every request; resolves once it has a port. */
|
||||
function healthServer(status) {
|
||||
const server = createServer((_req, res) => {
|
||||
res.writeHead(status).end('');
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Runs `fn` with console.log captured, and returns the lines it wrote. */
|
||||
function captureLog(fn) {
|
||||
const lines = [];
|
||||
const original = console.log;
|
||||
console.log = (line) => lines.push(String(line));
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
console.log = original;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
describe('servePort', () => {
|
||||
it('defaults to 5678', () => {
|
||||
delete process.env.N8N_PORT;
|
||||
assert.equal(servePort(), '5678');
|
||||
});
|
||||
|
||||
it('honours N8N_PORT', () => {
|
||||
process.env.N8N_PORT = '5679';
|
||||
assert.equal(servePort(), '5679');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serveHealthPath', () => {
|
||||
it('defaults to /healthz', () => {
|
||||
delete process.env.N8N_ENDPOINT_HEALTH;
|
||||
assert.equal(serveHealthPath(), '/healthz');
|
||||
});
|
||||
|
||||
// The backend config takes the path without a slash, so both spellings must work.
|
||||
it('adds the leading slash when the config omits it', () => {
|
||||
process.env.N8N_ENDPOINT_HEALTH = 'alive';
|
||||
assert.equal(serveHealthPath(), '/alive');
|
||||
});
|
||||
|
||||
it('does not double the leading slash', () => {
|
||||
process.env.N8N_ENDPOINT_HEALTH = '/alive';
|
||||
assert.equal(serveHealthPath(), '/alive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serveUrl', () => {
|
||||
it('builds the forwarded codespace URL', () => {
|
||||
process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN = DOMAIN;
|
||||
assert.equal(serveUrl('5678', NAME), `https://${NAME}-5678.${DOMAIN}`);
|
||||
});
|
||||
|
||||
it('falls back to localhost off a codespace', () => {
|
||||
assert.equal(serveUrl('5678', undefined), 'http://localhost:5678');
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForHealth', () => {
|
||||
it('resolves true once the endpoint answers ok', async () => {
|
||||
const { server, port } = await healthServer(200);
|
||||
try {
|
||||
assert.equal(await waitForHealth(port, '/healthz', 5000, 10), true);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
// A booting backend answers 503 before it is ready; that must not count as up.
|
||||
it('does not accept a non-ok status', async () => {
|
||||
const { server, port } = await healthServer(503);
|
||||
try {
|
||||
assert.equal(await waitForHealth(port, '/healthz', 60, 10), false);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves false when nothing is listening', async () => {
|
||||
assert.equal(await waitForHealth(1, '/healthz', 60, 10), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportUp', () => {
|
||||
it('reports an org-shared port as reachable by the org', () => {
|
||||
process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN = DOMAIN;
|
||||
const lines = captureLog(() => reportUp('5678', { name: NAME, shared: true }));
|
||||
assert.match(lines[0], new RegExp(`Up: https://${NAME}-5678\\.${DOMAIN}`));
|
||||
assert.match(lines[1], /org-visible/);
|
||||
});
|
||||
|
||||
// The share can fail while the backend is up, so say so and give the retry.
|
||||
it('reports a failed share with the retry command', () => {
|
||||
const lines = captureLog(() =>
|
||||
reportUp('5678', { name: NAME, shared: false, error: 'missing scope' }),
|
||||
);
|
||||
assert.match(lines[1], /still private: missing scope/);
|
||||
assert.match(lines[1], new RegExp(`gh codespace ports visibility 5678:org -c ${NAME}`));
|
||||
});
|
||||
|
||||
it('prints only the localhost URL off a codespace', () => {
|
||||
const lines = captureLog(() =>
|
||||
reportUp('5678', { shared: false }, join(tmpdir(), 'no-such-codespaces-dir')),
|
||||
);
|
||||
assert.deepEqual(lines, ['\nUp: http://localhost:5678']);
|
||||
});
|
||||
|
||||
// The bug this helper exists for: a tmux-started process can see an empty box
|
||||
// name, so the port is never shared. Saying "localhost" alone would hide that.
|
||||
it('flags a codespace whose box name did not resolve', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codespaces-marker-'));
|
||||
const lines = captureLog(() => reportUp('5678', { shared: false }, dir));
|
||||
assert.equal(lines[0], '\nUp: http://localhost:5678');
|
||||
assert.match(lines[1], /the box name did not resolve, so 5678 was not shared/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user