diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e8d07d..31e3edd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,10 @@ jobs: run: pnpm exec playwright install --with-deps chromium - name: Run tests - run: pnpm coverage:ci + run: JABTERM_SKIP_BUILD=1 pnpm coverage:ci + + - name: Run docs scenario tests (docs assets) + run: pnpm run test:docs - name: Upload coverage artifacts if: always() diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..97fc531 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# Agents + +If you are an AI agent (or automation) working on this repository, follow the test contract described in: + +- [`agents/testing-strategy.md`](agents/testing-strategy.md) + diff --git a/package.json b/package.json index 77342fb..7a46c75 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,15 @@ "scripts": { "build": "tsc -p tsconfig.server.json && tsc -p tsconfig.react.json", "prepack": "pnpm build", - "test": "pnpm exec playwright test", - "test:client": "pnpm exec vitest run", - "test:server:coverage": "rm -rf .cache/v8-coverage && NODE_V8_COVERAGE=.cache/v8-coverage pnpm test", + "test": "pnpm run build && test-runner all", + "test:unit": "test-runner unit", + "test:e2e": "pnpm run build && test-runner e2e", + "test:scenario": "pnpm run build && test-runner scenario", + "test:smoke": "pnpm run build && test-runner scenario --smoke", + "test:integration": "node -e \"console.log('No integration tests in this repo. (intentional)')\"", + "test:docs": "pnpm run build && test-runner e2e --project docs", + "test:client": "pnpm run test:unit", + "test:server:coverage": "rm -rf .cache/v8-coverage && pnpm run build && NODE_V8_COVERAGE=.cache/v8-coverage test-runner scenario && NODE_V8_COVERAGE=.cache/v8-coverage test-runner e2e", "test:client:coverage": "rm -rf .cache/coverage/client && pnpm exec vitest run --coverage", "coverage:server:report": "rm -rf .cache/coverage/server && pnpm exec c8 report --temp-directory .cache/v8-coverage --reporter=text-summary --reporter=json-summary --report-dir .cache/coverage/server --all --src src/server --exclude \"**/playwright.config.ts\" --exclude \"tests/**\" --exclude \"src/react/**\" && node scripts/normalize-coverage-summary.mjs .cache/coverage/server/coverage-summary.json", "coverage:merge": "node scripts/merge-coverage-summaries.mjs .cache/coverage/server/coverage-summary.json .cache/coverage/client/coverage-summary.json coverage/coverage-summary.json", @@ -59,6 +65,7 @@ "@testing-library/jest-dom": "^6.9.0", "@testing-library/react": "^16.3.0", "@vitest/coverage-v8": "^3.2.4", + "test-runner": "file:packages/test-runner", "c8": "^10.1.3", "jsdom": "^26.1.0", "react": "^19.2.0", diff --git a/packages/test-runner/README.md b/packages/test-runner/README.md new file mode 100644 index 0000000..5fd0ced --- /dev/null +++ b/packages/test-runner/README.md @@ -0,0 +1,69 @@ +# test-runner + +Small, policy-light test runner intended to be copied between repos (or published). + +## CLI + +```bash +test-runner [--smoke] [--human] [--pkg ...] [--project ] [--allow-missing-project] +``` + +Suites: + +- `unit`: run unit tests (Vitest if installed; otherwise `node --test`) +- `scenario`: run Playwright project `scenario` (recommended naming: `*.scenario.e2e.ts`) +- `e2e`: run Playwright project `e2e` (recommended naming: `*.e2e.ts`, ignoring scenario) +- `integration`: run Playwright project `integration` (optional) +- `all`: `unit` + `scenario` + `e2e` (never runs `integration`) + +Smoke rules (`--smoke`) apply to Playwright suites only: + +- per-test timeout via `SMOKE_PER_TEST_TIMEOUT_MS` (default 30000) +- total timeout via `SMOKE_TOTAL_TIMEOUT_MS` (default 180000) +- stop on first failure +- dot reporter output goes to `run.log` +- terminal output: **one line only** on success / failure + +Human execution (`--human`) is orthogonal: + +- Playwright: `--headed`, `--workers=1`, `--trace=on` +- Exposes `TEST_RUNNER_HUMAN=1` for test utilities (see `test-runner/human`) + +## Artifacts + +Each run writes to: + +`.cache/tests//` (cleaned before each run) + +With: + +- `run.log`: full raw output +- `pw-output/`: Playwright output directory (traces/videos/screenshots if enabled by config) + +## Playwright config convention (recommended) + +Define projects so the runner doesn’t need tags or discovery logic: + +```ts +import { defineConfig } from "@playwright/test"; + +const E2E = /.*\.e2e\.(ts|js)x?$/; +const SCENARIO = /.*\.scenario\.e2e\.(ts|js)x?$/; + +export default defineConfig({ + testDir: "tests", + projects: [ + { name: "e2e", testMatch: E2E, testIgnore: [SCENARIO] }, + { name: "scenario", testMatch: SCENARIO }, + ], +}); +``` + +## Test helper: `breath()` + +```js +import { breath } from "test-runner/human"; + +await breath(300); +``` + diff --git a/packages/test-runner/bin/test-runner.mjs b/packages/test-runner/bin/test-runner.mjs new file mode 100755 index 0000000..f198abe --- /dev/null +++ b/packages/test-runner/bin/test-runner.mjs @@ -0,0 +1,436 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; + +const ROOT = process.cwd(); + +function getPackageManager(rootDir) { + try { + const pkg = JSON.parse( + fs.readFileSync(path.join(rootDir, "package.json"), "utf8"), + ); + const pm = String(pkg?.packageManager || ""); + if (pm.startsWith("pnpm@")) return "pnpm"; + if (pm.startsWith("npm@")) return "npm"; + } catch { + /* ignore */ + } + if (fs.existsSync(path.join(rootDir, "pnpm-lock.yaml"))) return "pnpm"; + if (fs.existsSync(path.join(rootDir, "package-lock.json"))) return "npm"; + return "npm"; +} + +const PM = getPackageManager(ROOT); + +function pmExecArgs(bin, args) { + if (PM === "npm") return ["exec", "--", bin, ...args]; + return ["exec", bin, ...args]; +} + +async function rmrf(p) { + await fsp.rm(p, { recursive: true, force: true }); +} + +async function mkdirp(p) { + await fsp.mkdir(p, { recursive: true }); +} + +function spawnTee(cmd, args, { cwd, env, logStream, passthrough }) { + return new Promise((resolve) => { + const child = spawn(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); + + child.stdout.on("data", (chunk) => { + logStream.write(chunk); + if (passthrough) process.stdout.write(chunk); + }); + child.stderr.on("data", (chunk) => { + logStream.write(chunk); + if (passthrough) process.stderr.write(chunk); + }); + + child.on("close", (code, signal) => + resolve({ code: code ?? 0, signal }), + ); + }); +} + +function spawnCapture(cmd, args, { cwd, env }) { + return new Promise((resolve) => { + const child = spawn(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); + let out = ""; + let err = ""; + child.stdout.on("data", (c) => (out += c.toString("utf8"))); + child.stderr.on("data", (c) => (err += c.toString("utf8"))); + child.on("close", (code) => resolve({ code: code ?? 0, out, err })); + }); +} + +function sanitizePathSegment(s) { + return String(s) + .trim() + .replace(/^@/, "") + .replace(/[\/@]/g, "__") + .replace(/[^a-zA-Z0-9_.-]+/g, "_") + .slice(0, 120); +} + +function runId({ suite, smoke, human }) { + const parts = [`test-${suite}`]; + if (smoke) parts.push("smoke"); + if (human) parts.push("human"); + return parts.join("__"); +} + +function artifactsDirFor({ runId: rid, pkgName }) { + const base = path.join(ROOT, ".cache", "tests", rid); + return pkgName ? path.join(base, sanitizePathSegment(pkgName)) : base; +} + +function readPkgName(dir) { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")); + return pkg?.name || path.basename(dir); + } catch { + return path.basename(dir); + } +} + +function pkgHasDep(dir, depName) { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")); + return Boolean(pkg?.dependencies?.[depName] || pkg?.devDependencies?.[depName]); + } catch { + return false; + } +} + +async function listWorkspacePackages() { + const isPnpmWs = + PM === "pnpm" && fs.existsSync(path.join(ROOT, "pnpm-workspace.yaml")); + if (!isPnpmWs) return [{ name: readPkgName(ROOT), dir: ROOT }]; + + const res = await spawnCapture( + PM, + ["-r", "ls", "--depth", "-1", "--json"], + { cwd: ROOT, env: process.env }, + ); + if (res.code !== 0) return [{ name: readPkgName(ROOT), dir: ROOT }]; + + try { + const parsed = JSON.parse(res.out); + const pkgs = Array.isArray(parsed) ? parsed : []; + const out = []; + for (const p of pkgs) { + const dir = p?.path; + if (!dir || typeof dir !== "string") continue; + if (!fs.existsSync(path.join(dir, "package.json"))) continue; + out.push({ name: p?.name || readPkgName(dir), dir }); + } + return out.length ? out : [{ name: readPkgName(ROOT), dir: ROOT }]; + } catch { + return [{ name: readPkgName(ROOT), dir: ROOT }]; + } +} + +function parsePlaywrightCounts(logText) { + const totalMatch = logText.match(/Running\s+(\d+)\s+tests?\b/); + const total = totalMatch ? parseInt(totalMatch[1], 10) : 0; + + // Prefer the final "X passed" summary when available. + const passedMatches = Array.from(logText.matchAll(/^\s*(\d+)\s+passed\b/mg)); + const passed = passedMatches.length + ? parseInt(passedMatches.at(-1)[1], 10) + : 0; + + return { total, passed }; +} + +function parseFirstFailureHeadline(logText) { + const m = logText.match(/^\s*\d+\)\s+(.+)$/m); + if (!m) return null; + return m[1].replace(/\s+/g, " ").trim().slice(0, 180); +} + +function truncateOneLine(s, max = 160) { + const one = String(s || "").replace(/\s+/g, " ").trim(); + if (one.length <= max) return one; + return `${one.slice(0, max - 1)}…`; +} + +async function runUnit({ pkg, artifactsDir, logStream, passthrough }) { + const env = { ...process.env, TEST_ARTIFACTS_DIR: artifactsDir }; + + if (pkgHasDep(pkg.dir, "vitest")) { + return await spawnTee(PM, pmExecArgs("vitest", ["run"]), { + cwd: pkg.dir, + env, + logStream, + passthrough, + }); + } + + return await spawnTee(process.execPath, ["--test"], { + cwd: pkg.dir, + env, + logStream, + passthrough, + }); +} + +function shouldTreatMissingProjectAsSuccess(output, project) { + const text = String(output || ""); + return ( + text.includes(`Project(s) "${project}" not found`) || + text.includes(`Project(s) '${project}' not found`) || + text.includes("Unknown project") || + text.includes("No projects matched") + ); +} + +async function runPlaywright({ + pkg, + project, + smoke, + human, + artifactsDir, + logStream, + passthrough, + globalTimeoutMs, + allowMissingProject, +}) { + const env = { + ...process.env, + TEST_ARTIFACTS_DIR: artifactsDir, + ...(human ? { TEST_RUNNER_HUMAN: "1" } : {}), + }; + + const pwOut = path.join(artifactsDir, "pw-output"); + const args = [ + "test", + "--project", + project, + "--output", + pwOut, + "--pass-with-no-tests", + ...(human ? ["--headed", "--workers=1", "--trace=on"] : []), + ...(smoke + ? [ + "--max-failures=1", + "--workers=1", + "--reporter=dot", + "--timeout", + String( + parseInt(process.env.SMOKE_PER_TEST_TIMEOUT_MS || "30000", 10), + ), + "--global-timeout", + String(globalTimeoutMs), + ] + : []), + ]; + + const res = await spawnTee(PM, pmExecArgs("playwright", args), { + cwd: pkg.dir, + env, + logStream, + passthrough, + }); + + if (res.code !== 0 && allowMissingProject) { + const logPath = path.join(artifactsDir, "run.log"); + const logTxt = await fsp.readFile(logPath, "utf8").catch(() => ""); + if (shouldTreatMissingProjectAsSuccess(logTxt, project)) return { code: 0 }; + } + + return res; +} + +const argv = await yargs(hideBin(process.argv)) + .scriptName("test-runner") + .command( + "$0 ", + "Run tests", + (y) => + y + .positional("suite", { + choices: ["unit", "e2e", "scenario", "integration", "all"], + type: "string", + }) + .option("smoke", { + type: "boolean", + default: false, + describe: "Enable smoke rules (Playwright suites only)", + }) + .option("human", { + type: "boolean", + default: false, + describe: "Human execution mode (headed + trace + paced via breath())", + }) + .option("pkg", { + type: "array", + describe: "Workspace package name(s) to run (default: all packages)", + }) + .option("project", { + type: "string", + describe: "Override Playwright project name", + }) + .option("allow-missing-project", { + type: "boolean", + default: false, + describe: "Treat missing Playwright project as success", + }), + ) + .help() + .strict() + .parseAsync(); + +const SUITE = argv.suite; +const SMOKE = Boolean(argv.smoke); +const HUMAN = Boolean(argv.human); +const ONLY_PKGS = (argv.pkg || []).map(String); +const PROJECT_OVERRIDE = argv.project ? String(argv.project) : null; +const ALLOW_MISSING_PROJECT = Boolean(argv["allow-missing-project"]); + +const totalTimeoutMs = parseInt(process.env.SMOKE_TOTAL_TIMEOUT_MS || "180000", 10); +const warnAfterMs = parseInt(process.env.SMOKE_WARN_AFTER_MS || "60000", 10); + +const rid = runId({ suite: SUITE, smoke: SMOKE, human: HUMAN }); +const startedAt = Date.now(); + +const pkgsAll = await listWorkspacePackages(); +const pkgs = ONLY_PKGS.length + ? pkgsAll.filter((p) => ONLY_PKGS.includes(p.name)) + : pkgsAll; + +if (pkgs.length === 0) { + console.error(`No matching packages for --pkg=${ONLY_PKGS.join(", ")}`); + process.exit(2); +} + +let aggPassed = 0; +let aggTotal = 0; + +for (const pkg of pkgs) { + const multi = pkgs.length > 1; + const artifactsDir = artifactsDirFor({ runId: rid, pkgName: multi ? pkg.name : null }); + await rmrf(artifactsDir); + await mkdirp(artifactsDir); + + const logPath = path.join(artifactsDir, "run.log"); + const logStream = fs.createWriteStream(logPath, { flags: "w" }); + + const elapsedMs = Date.now() - startedAt; + const remainingMs = Math.max(1000, totalTimeoutMs - elapsedMs); + + try { + if (SUITE === "unit") { + const res = await runUnit({ pkg, artifactsDir, logStream, passthrough: true }); + if (res.code !== 0) process.exit(res.code || 1); + continue; + } + + if (SUITE === "all") { + const unitDir = path.join(artifactsDir, "unit"); + await rmrf(unitDir); + await mkdirp(unitDir); + const u = await runUnit({ + pkg, + artifactsDir: unitDir, + logStream, + passthrough: true, + }); + if (u.code !== 0) process.exit(u.code || 1); + + if (!pkgHasDep(pkg.dir, "@playwright/test")) continue; + + const sDir = path.join(artifactsDir, "scenario"); + await rmrf(sDir); + await mkdirp(sDir); + const s = await runPlaywright({ + pkg, + project: PROJECT_OVERRIDE || "scenario", + smoke: false, + human: false, + artifactsDir: sDir, + logStream, + passthrough: true, + globalTimeoutMs: remainingMs, + allowMissingProject: ALLOW_MISSING_PROJECT, + }); + if (s.code !== 0) process.exit(s.code || 1); + + const eDir = path.join(artifactsDir, "e2e"); + await rmrf(eDir); + await mkdirp(eDir); + const e = await runPlaywright({ + pkg, + project: PROJECT_OVERRIDE || "e2e", + smoke: false, + human: false, + artifactsDir: eDir, + logStream, + passthrough: true, + globalTimeoutMs: remainingMs, + allowMissingProject: ALLOW_MISSING_PROJECT, + }); + if (e.code !== 0) process.exit(e.code || 1); + + continue; + } + + // For Playwright suites: skip packages without Playwright. + if (!pkgHasDep(pkg.dir, "@playwright/test")) continue; + + const project = + PROJECT_OVERRIDE || + (SUITE === "scenario" + ? "scenario" + : SUITE === "e2e" + ? "e2e" + : "integration"); + + const res = await runPlaywright({ + pkg, + project, + smoke: SMOKE, + human: HUMAN, + artifactsDir, + logStream, + passthrough: !SMOKE, + globalTimeoutMs: remainingMs, + allowMissingProject: ALLOW_MISSING_PROJECT, + }); + + if (!SMOKE) { + if (res.code !== 0) process.exit(res.code || 1); + continue; + } + + const logTxt = await fsp.readFile(logPath, "utf8").catch(() => ""); + const { passed, total } = parsePlaywrightCounts(logTxt); + aggPassed += passed; + aggTotal += total; + + if (res.code !== 0) { + const headline = parseFirstFailureHeadline(logTxt); + const what = headline ? `${pkg.name}: ${headline}` : `${pkg.name}: exit ${res.code}`; + console.log( + `SMOKE FAIL: ${truncateOneLine(what)} | Artifacts: ${artifactsDir}`, + ); + process.exit(res.code || 1); + } + } finally { + await new Promise((resolve) => logStream.end(resolve)); + } +} + +if (SMOKE) { + const elapsedS = (Date.now() - startedAt) / 1000; + const ratio = aggTotal > 0 ? `${aggPassed}/${aggTotal}` : "all"; + const warnSuffix = elapsedS * 1000 > warnAfterMs ? " (warn: slow smoke)" : ""; + console.log(`SMOKE: ${ratio} passed in ${elapsedS.toFixed(1)}s${warnSuffix}`); +} + diff --git a/packages/test-runner/package.json b/packages/test-runner/package.json new file mode 100644 index 0000000..e47b290 --- /dev/null +++ b/packages/test-runner/package.json @@ -0,0 +1,21 @@ +{ + "name": "test-runner", + "version": "0.0.0", + "description": "Universal test runner (unit/e2e/scenario/smoke/human) with stable artifacts.", + "type": "module", + "license": "MIT", + "bin": { + "test-runner": "./bin/test-runner.mjs" + }, + "exports": { + "./human": "./src/human.mjs" + }, + "files": [ + "bin", + "src" + ], + "dependencies": { + "yargs": "^18.0.0" + } +} + diff --git a/packages/test-runner/src/human.mjs b/packages/test-runner/src/human.mjs new file mode 100644 index 0000000..c105c92 --- /dev/null +++ b/packages/test-runner/src/human.mjs @@ -0,0 +1,9 @@ +export function isHumanMode() { + return process.env.TEST_RUNNER_HUMAN === "1"; +} + +export async function breath(ms = 250) { + if (!isHumanMode()) return; + await new Promise((resolve) => setTimeout(resolve, ms)); +} + diff --git a/playwright.config.ts b/playwright.config.ts index a3c097b..d11e3d3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,23 +1,26 @@ import { defineConfig } from "@playwright/test"; const PORT = parseInt(process.env.JABTERM_PORT || "3223", 10); +const DEMO_PORT = PORT + 1; + +const E2E = /.*\.e2e\.(ts|js)x?$/; +const SCENARIO = /.*\.scenario\.e2e\.(ts|js)x?$/; +const DOCS = /.*\.docs\.e2e\.(ts|js)x?$/; export default defineConfig({ testDir: "./tests", - // Keep Playwright focused on E2E specs. Vitest unit tests live under `tests/unit/**` - // and use the `.test.*` suffix, which Playwright would otherwise pick up by default. - testMatch: ["**/*.spec.ts", "**/*.spec.tsx"], - outputDir: ".cache/test-results", fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, workers: 1, - reporter: [ - ["html", { outputFolder: ".cache/report", open: "never" }], - ], use: { - baseURL: `http://127.0.0.1:${PORT + 1}`, + baseURL: `http://127.0.0.1:${DEMO_PORT}`, }, + projects: [ + { name: "e2e", testMatch: E2E, testIgnore: [SCENARIO, DOCS] }, + { name: "scenario", testMatch: SCENARIO, testIgnore: [DOCS] }, + { name: "docs", testMatch: DOCS }, + ], webServer: [ { command: `node bin/jabterm-server.mjs --port ${PORT}`, @@ -35,13 +38,13 @@ export default defineConfig({ }, { command: `node tests/serve-demo.mjs`, - port: PORT + 1, + port: DEMO_PORT, reuseExistingServer: !process.env.CI, timeout: 10_000, stdout: "pipe", stderr: "pipe", env: { - DEMO_PORT: String(PORT + 1), + DEMO_PORT: String(DEMO_PORT), JABTERM_WS_PORT: String(PORT), ...(process.env.NODE_V8_COVERAGE ? { NODE_V8_COVERAGE: process.env.NODE_V8_COVERAGE } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58e9154..85742d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.4(react@19.2.4) + test-runner: + specifier: file:packages/test-runner + version: file:packages/test-runner typescript: specifier: ^5 version: 5.9.3 @@ -610,6 +613,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -667,6 +674,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -727,6 +737,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -997,6 +1011,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1023,6 +1041,10 @@ packages: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} + test-runner@file:packages/test-runner: + resolution: {directory: packages/test-runner, type: directory} + hasBin: true + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1184,6 +1206,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -1211,10 +1237,18 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1654,6 +1688,12 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrap-ansi: 9.0.2 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1698,6 +1738,8 @@ snapshots: eastasianwidth@0.2.0: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -1765,6 +1807,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.5.0: {} + glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -2052,6 +2096,12 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.1.2 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -2080,6 +2130,10 @@ snapshots: glob: 10.5.0 minimatch: 9.0.5 + test-runner@file:packages/test-runner: + dependencies: + yargs: 18.0.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -2232,6 +2286,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.1.2 + ws@8.19.0: {} xml-name-validator@5.0.0: {} @@ -2242,6 +2302,8 @@ snapshots: yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs@17.7.2: dependencies: cliui: 8.0.1 @@ -2252,4 +2314,13 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yocto-queue@0.1.0: {} diff --git a/tests/echo.spec.ts b/tests/echo.e2e.ts similarity index 99% rename from tests/echo.spec.ts rename to tests/echo.e2e.ts index 78d0505..fda620c 100644 --- a/tests/echo.spec.ts +++ b/tests/echo.e2e.ts @@ -59,3 +59,4 @@ test.describe("Terminal — echo round-trip (WS protocol)", () => { expect(result).toBe("open"); }); }); + diff --git a/tests/react-demo.spec.ts b/tests/react-demo.scenario.e2e.ts similarity index 79% rename from tests/react-demo.spec.ts rename to tests/react-demo.scenario.e2e.ts index 295a8ea..1e48258 100644 --- a/tests/react-demo.spec.ts +++ b/tests/react-demo.scenario.e2e.ts @@ -1,15 +1,23 @@ /** - * React demo smoke test. + * React demo scenario. * * Ensures the demo page exercises `jabterm/react` (mount/unmount, layout resize, * and unexpected close UI) rather than a plain xterm CDN implementation. */ import { test, expect } from "@playwright/test"; +import { breath } from "test-runner/human"; test.describe("React demo page", () => { test("mount/unmount and layout resize work", async ({ page }) => { - await page.goto("/"); + const consoleErrors: string[] = []; + page.on("pageerror", (err) => consoleErrors.push(err.message)); + page.on("console", (msg) => { + if (msg.type() === "error") consoleErrors.push(msg.text()); + }); + + const resp = await page.goto("/"); + expect(resp?.ok()).toBe(true); const term1 = page.locator('[data-testid="jabterm-1"] .xterm-screen'); await expect(term1).toBeVisible({ timeout: 15_000 }); @@ -63,16 +71,24 @@ test.describe("React demo page", () => { expect(after).not.toBeNull(); expect(Math.abs(after!.width - before!.width)).toBeGreaterThan(20); + expect(consoleErrors).toEqual([]); }); test("shows close message when shell exits", async ({ page }) => { - await page.goto("/"); + const consoleErrors: string[] = []; + page.on("pageerror", (err) => consoleErrors.push(err.message)); + page.on("console", (msg) => { + if (msg.type() === "error") consoleErrors.push(msg.text()); + }); + + const resp = await page.goto("/"); + expect(resp?.ok()).toBe(true); const term1 = page.locator('[data-testid="jabterm-1"] .xterm-screen'); await expect(term1).toBeVisible({ timeout: 15_000 }); await term1.click(); - await page.waitForTimeout(500); + await breath(500); await page.keyboard.type("exit", { delay: 10 }); await page.keyboard.press("Enter"); @@ -83,6 +99,8 @@ test.describe("React demo page", () => { await expect(state1).toHaveAttribute("data-jabterm-state", "closed", { timeout: 15_000, }); + + expect(consoleErrors).toEqual([]); }); }); diff --git a/tests/screenshot.spec.ts b/tests/screenshot.docs.e2e.ts similarity index 97% rename from tests/screenshot.spec.ts rename to tests/screenshot.docs.e2e.ts index 31a4723..45d58f0 100644 --- a/tests/screenshot.spec.ts +++ b/tests/screenshot.docs.e2e.ts @@ -87,13 +87,17 @@ test.describe("Terminal — README screenshots", () => { // Type in terminal 1 await term1.click(); - await page.keyboard.type('echo "Terminal 1 — Hello from JabTerm"', { delay: 20 }); + await page.keyboard.type('echo "Terminal 1 — Hello from JabTerm"', { + delay: 20, + }); await page.keyboard.press("Enter"); await page.waitForTimeout(1000); // Type in terminal 2 await term2.click(); - await page.keyboard.type('echo "Terminal 2 — Independent session"', { delay: 20 }); + await page.keyboard.type('echo "Terminal 2 — Independent session"', { + delay: 20, + }); await page.keyboard.press("Enter"); await page.waitForTimeout(1000); @@ -103,3 +107,4 @@ test.describe("Terminal — README screenshots", () => { }); }); }); + diff --git a/tests/server-api.spec.ts b/tests/server-api.e2e.ts similarity index 100% rename from tests/server-api.spec.ts rename to tests/server-api.e2e.ts diff --git a/tests/video.spec.ts b/tests/video.docs.e2e.ts similarity index 99% rename from tests/video.spec.ts rename to tests/video.docs.e2e.ts index 73cec56..7000ff5 100644 --- a/tests/video.spec.ts +++ b/tests/video.docs.e2e.ts @@ -161,3 +161,4 @@ test.describe("Terminal - demo video", () => { expect(gifSize).toBeGreaterThan(0); }); }); + diff --git a/tests/zombie.spec.ts b/tests/zombie.e2e.ts similarity index 99% rename from tests/zombie.spec.ts rename to tests/zombie.e2e.ts index 4c6be53..d7256c9 100644 --- a/tests/zombie.spec.ts +++ b/tests/zombie.e2e.ts @@ -139,3 +139,4 @@ test.describe("Terminal — zombie process prevention", () => { } }); }); +