Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/dashboard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,23 @@ jobs:
node --check "$f"
done

# `node --check` only parses; it never resolves imports, so a module that
# imports a name its target no longer exports passes the syntax check and
# then blows up in the browser with "does not provide an export named …".
# This walks the module graph and fails naming the missing export (#866).
- name: Module-graph link check
run: |
node --input-type=module -e "
import('./static/format.js').then(m => {
for (const n of ['buildListQuery','toCsv','CSV_COLUMNS','shortId'])
if (!(n in m)) throw new Error('static/format.js is missing export: ' + n);
});
import('./static/session.js').then(m => {
for (const n of ['getKey','setKey','clearKey'])
if (!(n in m)) throw new Error('static/session.js is missing export: ' + n);
});
"

- name: Accessibility smoke check
run: node scripts/check-dashboard-a11y.mjs

Expand All @@ -70,6 +87,57 @@ jobs:
- name: Run unit tests for the pure JS modules
run: node --test static/tests/*.test.js

# A fast browser smoke test that proves the dashboard actually loads in a
# real browser — independent of the Rust toolchain (#866). The e2e job below
# runs `cargo build` first, so a broken backend masks a broken frontend; this
# job serves `static/` with a tiny Node server and stubs `/v1/*`, so it fails
# on the frontend breakage itself with a message naming the missing export or
# undefined identifier. Chromium only, well under a minute.
dashboard-smoke:
name: dashboard browser smoke (no rust)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-smoke-${{ hashFiles('e2e/package-lock.json') }}
restore-keys: |
${{ runner.os }}-playwright-smoke-

- name: Install Playwright
working-directory: e2e
run: npm ci --no-audit --no-fund

- name: Install Chromium only
working-directory: e2e
run: npx playwright install --with-deps chromium

# Loads `/dashboard`, fails on any `pageerror` or `console.error`, signs
# in with a fixture key and asserts the payments table renders a row.
# No `cargo`, no Rust toolchain, no database.
- name: Run browser smoke test
working-directory: e2e
run: npx playwright test --project=chromium smoke

- name: Upload failure artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-smoke-report
path: |
e2e/playwright-report/
e2e/test-results/
retention-days: 7
if-no-files-found: ignore

# End-to-end smoke test against a real server and a seeded database
# (issue #724), plus the credential-leak regression assertion (#726) and
# keyboard-shortcut coverage (#721).
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1729,3 +1729,6 @@ Released under the [MIT License](LICENSE).

<!-- handsoff-issue-680 -->
- #680: Dashboard: convert promise chains to async/await

<!-- handsoff-issue-865 -->
- #865: Orphaned dashboard modules: `static/api.js`, `static/dom.js` and `static/dashboard-format.js` are never served or imported
150 changes: 122 additions & 28 deletions e2e/scripts/serve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,44 +7,138 @@
* orchestration lives here instead of in a shell one-liner in the workflow.
*
* Dev-only. Nothing in this directory is compiled into the gateway binary.
*
* Two modes:
* node e2e/scripts/serve.mjs spawn the built gateway binary
* node e2e/scripts/serve.mjs --static serve static/ with the gateway's
* path layout, no Rust toolchain
*
* The static mode exists so the dashboard smoke test can run in CI without
* `cargo build`. It maps the same URLs the gateway serves:
* /dashboard -> static/dashboard.html
* /dashboard/app.js -> static/dashboard.js
* /dashboard/x.css -> static/x.css
* and stubs /v1/* from e2e/fixtures/ so the page can sign in and render.
*/

import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { createServer } from "node:http";
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname, extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, "..", "..");
const binary = join(repoRoot, "target", "debug", "stellargate");

if (!existsSync(binary)) {
console.error(
`The gateway binary is missing at ${binary}.\n` +
`Build it first: cargo build --locked`,
);
process.exit(1);

if (process.argv.includes("--static")) {
serveStatic();
} else {
serveGateway();
}

const child = spawn(binary, [], {
cwd: repoRoot,
stdio: "inherit",
env: process.env,
});

/* Forward the signals Playwright sends on teardown, so the child gets a chance
to drain rather than being killed mid-write and leaving a locked SQLite
file behind for the next run. */
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
if (!child.killed) child.kill(signal);
function serveGateway() {
const binary = join(repoRoot, "target", "debug", "stellargate");

if (!existsSync(binary)) {
console.error(
`The gateway binary is missing at ${binary}.\n` +
`Build it first: cargo build --locked`,
);
process.exit(1);
}

const child = spawn(binary, [], {
cwd: repoRoot,
stdio: "inherit",
env: process.env,
});

/* Forward the signals Playwright sends on teardown, so the child gets a chance
to drain rather than being killed mid-write and leaving a locked SQLite
file behind for the next run. */
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
if (!child.killed) child.kill(signal);
});
}

child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
}

const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
};

/* Map a request path to a file under static/, mirroring the gateway's layout:
* `/dashboard` and `/dashboard/` serve dashboard.html, and `/dashboard/<name>`
* serves static/<name> (so `/dashboard/app.js` -> static/dashboard.js is
* handled by the caller's alias table). */
function resolveStatic(pathname) {
const staticDir = join(repoRoot, "static");
let rel = pathname.replace(/^\/+/, "");

if (rel === "" || rel === "dashboard" || rel === "dashboard/") {
return join(staticDir, "dashboard.html");
}
if (rel.startsWith("dashboard/")) {
rel = rel.slice("dashboard/".length);
// The page imports `./app.js`; the gateway serves it from dashboard.js.
if (rel === "app.js") rel = "dashboard.js";
}

const target = normalize(join(staticDir, rel));
if (!target.startsWith(staticDir)) return null;
return target;
}

child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
function serveStatic() {
const port = Number(process.env.PORT ?? 4173);
const fixturesDir = join(repoRoot, "e2e", "fixtures");

const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
const pathname = decodeURIComponent(url.pathname);

if (pathname.startsWith("/v1/")) {
// Stub the API from e2e/fixtures/<name>.json when present, else 200 {}.
const name = pathname.slice("/v1/".length).replace(/\//g, "_") || "index";
const fixture = join(fixturesDir, `${name}.json`);
const body = existsSync(fixture) ? readFileSync(fixture) : "{}";
res.writeHead(200, { "content-type": MIME[".json"] });
res.end(body);
return;
}

const file = resolveStatic(pathname);
if (!file || !existsSync(file) || !statSync(file).isFile()) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
res.end(`not found: ${pathname}\n`);
return;
}

res.writeHead(200, {
"content-type": MIME[extname(file)] ?? "application/octet-stream",
});
res.end(readFileSync(file));
});

server.listen(port, () => {
console.log(`static server listening on http://localhost:${port}`);
});

for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => server.close(() => process.exit(0)));
}
process.exit(code ?? 1);
});
}
Loading