From a3234001c5a9e3cf586942f484b7e12a0ab30f53 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 21 Jul 2026 13:10:46 -0400 Subject: [PATCH 1/4] test: add version-pinned end-to-end suite for generated apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds each template with the real create-harper CLI, exercises it against a real Harper instance, and lets the Harper version under test be pinned — including unpublished builds from a git commit — so we can catch API-surface changes and instability before a Harper release breaks a generated app. - template.tests/e2e/: bootHarper (shared isolated-instance module), overlay (a schema + custom resource + a frontend component that consumes the API), Playwright specs (auto-REST CRUD, custom resource, frontend, browser), and run.js (generate -> overlay -> install -> resolve Harper -> build -> test). - Harper resolution, no global install: HARPER_BIN, or --harper-ref (clone + build harperfast/harper from source), or --harper (default latest; next/beta/tarball too). - runtimeSmoke.js refactored onto the shared bootHarper module. - .github/workflows/e2e.yaml: nightly latest+next canary that files an issue on failure, plus workflow_dispatch (harper-version / harper-ref / template). - Fixtures live under template.tests/ (outside template-*/ and the npm files allowlist), so they can never reach a scaffolded app. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/e2e.yaml | 119 +++++++++++ .gitignore | 6 + .oxlintrc.json | 2 +- dprint.json | 3 +- package-lock.json | 64 ++++++ package.json | 2 + template.tests/e2e/README.md | 108 ++++++++++ template.tests/e2e/capabilities.js | 22 ++ .../e2e/fixtures/react/E2eWidgets.jsx | 36 ++++ .../e2e/fixtures/shared/resources/e2eEcho.js | 14 ++ .../e2e/fixtures/shared/schemas/e2e.graphql | 5 + .../e2e/fixtures/vue/E2eWidgets.vue | 34 +++ template.tests/e2e/globalSetup.js | 43 ++++ template.tests/e2e/harper.js | 179 ++++++++++++++++ template.tests/e2e/overlay.js | 140 +++++++++++++ template.tests/e2e/playwright.config.js | 36 ++++ template.tests/e2e/run.js | 194 ++++++++++++++++++ template.tests/e2e/specs/browser.spec.js | 22 ++ template.tests/e2e/specs/crud.spec.js | 46 +++++ template.tests/e2e/specs/frontend.spec.js | 22 ++ template.tests/e2e/specs/resource.spec.js | 15 ++ template.tests/runtimeSmoke.js | 88 ++------ 22 files changed, 1122 insertions(+), 78 deletions(-) create mode 100644 .github/workflows/e2e.yaml create mode 100644 template.tests/e2e/README.md create mode 100644 template.tests/e2e/capabilities.js create mode 100644 template.tests/e2e/fixtures/react/E2eWidgets.jsx create mode 100644 template.tests/e2e/fixtures/shared/resources/e2eEcho.js create mode 100644 template.tests/e2e/fixtures/shared/schemas/e2e.graphql create mode 100644 template.tests/e2e/fixtures/vue/E2eWidgets.vue create mode 100644 template.tests/e2e/globalSetup.js create mode 100644 template.tests/e2e/harper.js create mode 100644 template.tests/e2e/overlay.js create mode 100644 template.tests/e2e/playwright.config.js create mode 100644 template.tests/e2e/run.js create mode 100644 template.tests/e2e/specs/browser.spec.js create mode 100644 template.tests/e2e/specs/crud.spec.js create mode 100644 template.tests/e2e/specs/frontend.spec.js create mode 100644 template.tests/e2e/specs/resource.spec.js diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 0000000..3b6d156 --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -0,0 +1,119 @@ +name: E2E +on: + # Run on demand against any Harper version (a dist-tag like `latest`/`next`, an explicit + # version like `5.2.0-beta.1`, or any npm spec — including a tarball or git url). + workflow_dispatch: + inputs: + harper-version: + description: 'Harper npm spec to test (latest, next, 5.2.0-beta.1, a tarball/git url, ...)' + default: 'latest' + required: true + harper-ref: + description: 'Build Harper from this git commit/branch/tag instead (overrides harper-version)' + default: '' + required: false + template: + description: 'Template to test, or "all" for the representative matrix' + default: 'all' + required: true + # Nightly canary against the leading edge — the early warning for Harper prereleases breaking + # a generated app. Opens an issue on failure (see the notify job). + schedule: + - cron: '0 7 * * *' + +concurrency: + group: e2e-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: true + +jobs: + matrix: + name: Compute matrix + runs-on: ubuntu-latest + outputs: + templates: ${{ steps.set.outputs.templates }} + versions: ${{ steps.set.outputs.versions }} + steps: + - id: set + shell: bash + run: | + # Templates: a manually chosen single template, otherwise a representative subset — + # one per frontend family plus one SSR variant. The package manager is irrelevant to + # Harper's API surface, so (unlike the PR integration test) this axis is npm-only. + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.template }}" != "all" ]; then + echo "templates=[\"${{ inputs.template }}\"]" >> "$GITHUB_OUTPUT" + else + echo 'templates=["vanilla-ts","react-ts","vue-ts","react-ts-ssr"]' >> "$GITHUB_OUTPUT" + fi + # Versions: a single cell when building from a git ref (the ref is the only dimension), + # the dispatched npm spec, otherwise latest + next for the nightly canary. + if [ -n "${{ inputs.harper-ref }}" ]; then + echo 'versions=["(source)"]' >> "$GITHUB_OUTPUT" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo 'versions=["${{ inputs.harper-version }}"]' >> "$GITHUB_OUTPUT" + else + echo 'versions=["latest","next"]' >> "$GITHUB_OUTPUT" + fi + + e2e: + needs: matrix + name: ${{ matrix.template }} @ harper@${{ matrix.harper-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + template: ${{ fromJSON(needs.matrix.outputs.templates) }} + harper-version: ${{ fromJSON(needs.matrix.outputs.versions) }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version-file: '.nvmrc' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Cache Playwright browsers + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Run E2E (${{ matrix.template }} against harper ${{ matrix.harper-version }}) + run: | + if [ -n "${{ inputs.harper-ref }}" ]; then + node template.tests/e2e/run.js --template ${{ matrix.template }} --harper-ref "${{ inputs.harper-ref }}" + else + node template.tests/e2e/run.js --template ${{ matrix.template }} --harper "${{ matrix.harper-version }}" + fi + + notify: + needs: e2e + # Only the scheduled canary auto-files an issue; manual runs surface in the Actions UI. + if: ${{ failure() && github.event_name == 'schedule' }} + runs-on: ubuntu-latest + permissions: + issues: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - name: File an issue for the failing canary (deduped) + run: | + gh label create e2e-canary --description "Nightly E2E canary failures" --color B60205 --force || true + open=$(gh issue list --label e2e-canary --state open --json number --jq 'length') + run_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + if [ "$open" = "0" ]; then + gh issue create --label e2e-canary \ + --title "E2E canary failing against a Harper prerelease" \ + --body "The nightly E2E canary failed — a generated app may break on an upcoming Harper release. Run: ${run_url}" + else + echo "An open e2e-canary issue already exists; commenting instead of duplicating." + number=$(gh issue list --label e2e-canary --state open --json number --jq '.[0].number') + gh issue comment "$number" --body "Canary failed again: ${run_url}" + fi diff --git a/.gitignore b/.gitignore index 7575d61..bb9bc11 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ .DS_Store .temp-integration-tests +# Playwright e2e artifacts +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ + # # https://raw.githubusercontent.com/github/gitignore/refs/heads/main/Node.gitignore # diff --git a/.oxlintrc.json b/.oxlintrc.json index c27d1a5..94d4fcf 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "ignorePatterns": [], + "ignorePatterns": ["template.tests/e2e/fixtures/**"], "rules": {}, "overrides": [ { diff --git a/dprint.json b/dprint.json index eafcdfa..2fda416 100644 --- a/dprint.json +++ b/dprint.json @@ -18,7 +18,8 @@ }, "excludes": [ "**/node_modules", - "template-early-hints/schemas/schema.graphql" + "template-early-hints/schemas/schema.graphql", + "template.tests/e2e/fixtures" ], "plugins": [ "https://plugins.dprint.dev/typescript-0.95.13.wasm", diff --git a/package-lock.json b/package-lock.json index b5c548b..8b5bea9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "devDependencies": { "@commitlint/cli": "^21.0.0", "@commitlint/config-conventional": "^21.0.0", + "@playwright/test": "^1.61.1", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^12.0.2", @@ -1342,6 +1343,22 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -7267,6 +7284,53 @@ "node": ">=4" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", diff --git a/package.json b/package.json index e5acb61..d40196b 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", + "test:e2e": "node template.tests/e2e/run.js", "templates:apply-shared-templates": "(cd templates-shared && node ./applySharedTemplates.js)", "templates:build-studio-templates": "(cd templates-studio && node ./buildStudioTemplates.js)", "templates:publish-studio-templates": "(cd templates-studio && node ./publishStudioTemplates.js)" @@ -65,6 +66,7 @@ "devDependencies": { "@commitlint/cli": "^21.0.0", "@commitlint/config-conventional": "^21.0.0", + "@playwright/test": "^1.61.1", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^12.0.2", diff --git a/template.tests/e2e/README.md b/template.tests/e2e/README.md new file mode 100644 index 0000000..040f566 --- /dev/null +++ b/template.tests/e2e/README.md @@ -0,0 +1,108 @@ +# End-to-end tests + +These tests scaffold a real app with the create-harper CLI, exercise it against a **real Harper +instance**, and let you pin **which version of Harper** to run against — so we can catch, ahead +of a Harper release, anything that would break a generated app: changed API surfaces, new +instability, or defaults create-harper needs to follow. + +This is the deeper tier above [`../runtimeSmoke.js`](../runtimeSmoke.js) (the fast per-PR check). +It shares the same isolated-Harper boot module, [`harper.js`](./harper.js). + +## Running locally + +```bash +# One template against the latest published Harper: +npm run test:e2e -- --template react-ts + +# Against the leading edge (the `next` dist-tag), or any npm spec: +npm run test:e2e -- --template react-ts --harper next +npm run test:e2e -- --template vue-ts --harper 5.2.0-beta.1 + +# Against an UNPUBLISHED Harper, built from a git commit / branch / tag: +npm run test:e2e -- --template react-ts --harper-ref 1e1edc6 +npm run test:e2e -- --template react-ts --harper-ref my-feature-branch +npm run test:e2e -- --template react-ts --harper-ref abc123 --harper-repo https://github.com/me/harper.git + +# Reuse a Harper you already have installed/built (skips resolution entirely): +HARPER_BIN=/path/to/node_modules/.bin/harper npm run test:e2e -- --template vanilla-ts + +# Keep the generated app around to inspect a failure: +npm run test:e2e -- --template react-ts --keep +``` + +How Harper is resolved, in precedence order: + +- `HARPER_BIN` (env) — an existing install or build; `run.js` uses it as-is. A `.js` path (e.g. a + source build's `dist/bin/harper.js`) is launched via `node`. +- `--harper-ref ` — clone `harperfast/harper` (public; override with + `--harper-repo`), `npm install` + `npm run build`, and run its `dist/bin/harper.js`. This tests + an unpublished Harper straight from a commit. Heaviest path (full install + `tsc` build). +- `--harper ` (default `latest`) — `npm install harper@` into an isolated prefix. Any + npm spec: `latest`, `next`, `5.2.0-beta.1`, a tarball, ... + +Nothing is installed globally in any case. + +## What a run does + +[`run.js`](./run.js) drives one template end-to-end: + +1. **Generate** the app with the real CLI (throwaway temp dir). +2. **Overlay** e2e fixtures ([`overlay.js`](./overlay.js)) — see "no cruft" below. +3. **Install** the app's dependencies. +4. **Resolve Harper** (no global install): a published version via npm, or an unpublished build + from a git ref — see the precedence list above. +5. **Build** the app if it has a build step. +6. **Playwright** ([`playwright.config.js`](./playwright.config.js)) boots one isolated Harper via + [`globalSetup.js`](./globalSetup.js), seeds a known record, and runs the specs against it. + +## Scenarios + +Every template runs the API specs (CRUD + custom resource — the framework-agnostic Harper +surface). SPA templates additionally run the frontend-served and browser specs. + +| Spec | What it proves | Templates | +| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | +| [`crud.spec.js`](./specs/crud.spec.js) | Schema-driven auto-REST CRUD lifecycle (create/read/update/filter/delete), with the exact status codes asserted as a version canary | all | +| [`resource.spec.js`](./specs/resource.spec.js) | A custom `resources/` class loads and answers GET with its own JSON | all | +| [`frontend.spec.js`](./specs/frontend.spec.js) | The frontend is served at `/` | react / vue / vanilla (SPA) | +| [`browser.spec.js`](./specs/browser.spec.js) | A real framework component fetches the auto-REST API and renders the data in a browser | react / vue / vanilla (SPA) | + +> Note: there is **no GraphQL query-over-HTTP endpoint** in Harper (verified: `/graphql` → 404). +> "GraphQL" in the templates means the schema definition language plus the auto-generated REST + +> WebSocket APIs — which is what these specs exercise. + +## No cruft in real users' apps + +The fixtures under [`fixtures/`](./fixtures) are **only** ever copied into throwaway generated +copies, never into the `template-*/` sources, and `template.tests/` is excluded from the npm +`files` allowlist. So none of this can reach a user's scaffolded app or the published package. + +- **Shared** ([`fixtures/shared`](./fixtures/shared)) — a schema (`E2eWidget`) + a custom resource + (`E2eEcho`), applied to every template. This is Harper's API surface, which is framework-agnostic. +- **Framework** ([`fixtures/react`](./fixtures/react), [`fixtures/vue`](./fixtures/vue)) — a + component that fetches `E2eWidget` and renders it, mounted into the app's entry by + [`overlay.js`](./overlay.js). Vanilla gets an equivalent plain-DOM overlay. + +## CI + +- **Per PR** — the fast `runtimeSmoke.js` check (via `.github/workflows/integration.yaml`), against + the latest published Harper. +- **Nightly + on demand** — `.github/workflows/e2e.yaml` runs this suite. The scheduled run tests + `latest` **and** `next` across a representative template subset and files an issue if the `next` + canary breaks. `workflow_dispatch` takes `harper-version` (an npm spec), `harper-ref` (a git + commit/branch/tag to build from source — overrides `harper-version`), and optional `template`. + +## Extending + +- **SSR frontend + browser coverage**: SSR templates (`*-ssr`) currently run the API specs only. + Two things differ from SPA: their entry is an `entry-client`/`entry-server` split rather than a + single `main` (so the component overlay is gated off — `capabilities.js` → `browser: false`), and + their `static` handler sets `index: false` so `/` is served by the SSR entry-server. In local + validation that path returned **404 under `harper run`** — so the frontend spec is gated off for + SSR too (`E2E_SSR`). Confirming the intended production SSR serving, then enabling both specs for + SSR, is the natural next step (and a good first real use of this harness). +- **More scenarios**: add a `*.spec.js` under [`specs/`](./specs). API specs use the `request` + fixture (authenticated as the seeded superuser); browser specs use `page` and should be gated on + `E2E_BROWSER`. +- **New capabilities per template**: [`capabilities.js`](./capabilities.js) derives everything from + the template name. diff --git a/template.tests/e2e/capabilities.js b/template.tests/e2e/capabilities.js new file mode 100644 index 0000000..87efa1c --- /dev/null +++ b/template.tests/e2e/capabilities.js @@ -0,0 +1,22 @@ +/** + * Derives what an e2e run should do for a given template, entirely from its canonical name. + * + * - ext: resources are `.ts` for TypeScript templates, `.js` otherwise. + * - framework: which frontend overlay to apply (react / vue / vanilla). + * - ssr: server-rendered templates use an entry-client/entry-server split rather than a + * single `main` entry, so the browser-component overlay doesn't apply (yet). They + * still run the full HTTP suite (CRUD, custom resource, frontend-served). + * - browser: whether to apply the frontend component overlay and run the browser spec. + */ + +/** @param {string} templateName */ +export function capabilitiesFor(templateName) { + const ext = templateName.includes('-ts') ? 'ts' : 'js'; + const framework = templateName.startsWith('react') + ? 'react' + : templateName.startsWith('vue') + ? 'vue' + : 'vanilla'; + const ssr = templateName.endsWith('-ssr'); + return { templateName, ext, framework, ssr, browser: !ssr }; +} diff --git a/template.tests/e2e/fixtures/react/E2eWidgets.jsx b/template.tests/e2e/fixtures/react/E2eWidgets.jsx new file mode 100644 index 0000000..21ea10c --- /dev/null +++ b/template.tests/e2e/fixtures/react/E2eWidgets.jsx @@ -0,0 +1,36 @@ +/** + * A React component that fetches the exported E2eWidget table over Harper's auto-REST API and + * renders the rows. Copied into the app under test as src/E2eWidgets.{jsx,tsx} and mounted by an + * overlay appended to the app's entry (see template.tests/e2e/overlay.js). The browser spec + * asserts the seeded record shows up in the DOM — proving a real component can read the API. + * + * Deliberately untyped so the one file is valid as both .jsx and .tsx (vite build strips types + * without type-checking). + */ +import { useEffect, useState } from "react"; + +export function E2eWidgets() { + const [items, setItems] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/E2eWidget/", { headers: { Accept: "application/json" } }) + .then((response) => response.json()) + .then((data) => setItems(Array.isArray(data) ? data : [])) + .catch((cause) => setError(String(cause))); + }, []); + + return ( +
+

E2E Widgets

+ {error ?

{error}

: null} +
    + {items.map((widget) => ( +
  • + {widget.name} +
  • + ))} +
+
+ ); +} diff --git a/template.tests/e2e/fixtures/shared/resources/e2eEcho.js b/template.tests/e2e/fixtures/shared/resources/e2eEcho.js new file mode 100644 index 0000000..9efc8ea --- /dev/null +++ b/template.tests/e2e/fixtures/shared/resources/e2eEcho.js @@ -0,0 +1,14 @@ +/** + * A minimal custom resource, copied into the app under test as resources/e2eEcho.{js,ts}. + * `Resource` is a Harper runtime global (no import needed), matching how the templates document + * custom resources. The e2e suite hits GET /E2eEcho/ to prove jsResource loading + the REST + * handler answer a custom resource with JSON. + */ +export class E2eEcho extends Resource { + allowRead() { + return true; + } + async get() { + return { echo: 'ok' }; + } +} diff --git a/template.tests/e2e/fixtures/shared/schemas/e2e.graphql b/template.tests/e2e/fixtures/shared/schemas/e2e.graphql new file mode 100644 index 0000000..05f5183 --- /dev/null +++ b/template.tests/e2e/fixtures/shared/schemas/e2e.graphql @@ -0,0 +1,5 @@ +type E2eWidget @table @export { + id: ID @primaryKey + name: String + quantity: Int @indexed +} diff --git a/template.tests/e2e/fixtures/vue/E2eWidgets.vue b/template.tests/e2e/fixtures/vue/E2eWidgets.vue new file mode 100644 index 0000000..f9201aa --- /dev/null +++ b/template.tests/e2e/fixtures/vue/E2eWidgets.vue @@ -0,0 +1,34 @@ + + + + diff --git a/template.tests/e2e/globalSetup.js b/template.tests/e2e/globalSetup.js new file mode 100644 index 0000000..d084569 --- /dev/null +++ b/template.tests/e2e/globalSetup.js @@ -0,0 +1,43 @@ +/** + * Playwright global setup: boot one isolated Harper instance for the whole run, wait until it + * accepts authenticated requests, and seed a known record so the browser scenario has data to + * render. Returns a teardown function (Playwright calls it after the run) that stops Harper and + * removes its scratch root. + */ +import { bootHarper } from './harper.js'; + +/** A record the browser spec expects to see rendered by the frontend component. */ +export const SEED_WIDGET = { id: 'seed-1', name: 'E2E Seed Widget', quantity: 1 }; + +export default async function globalSetup() { + const appDir = process.env.E2E_APP_DIR; + if (!appDir) { + throw new Error('E2E_APP_DIR is not set — run the suite via template.tests/e2e/run.js'); + } + + const harper = await bootHarper({ appDir, onLog: (chunk) => process.stdout.write(chunk) }); + + try { + // The exported table's REST endpoint is auth-gated, so a successful GET proves the schema + // loaded AND the auth store is ready — both required before the specs run. + await harper.waitUntilReady('/E2eWidget/'); + + const seed = await fetch(`${harper.baseUrl}/E2eWidget/`, { + method: 'POST', + headers: { + Authorization: harper.authorization, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(SEED_WIDGET), + }); + if (!seed.ok) { + throw new Error(`Failed to seed E2eWidget: ${seed.status} ${await seed.text()}`); + } + } catch (error) { + harper.stop(); + throw error; + } + + return () => harper.stop(); +} diff --git a/template.tests/e2e/harper.js b/template.tests/e2e/harper.js new file mode 100644 index 0000000..90e1d24 --- /dev/null +++ b/template.tests/e2e/harper.js @@ -0,0 +1,179 @@ +/** + * Boots an isolated Harper instance for end-to-end testing of a generated app. + * + * This is the shared core used by both the quick `runtimeSmoke.js` and the full Playwright + * e2e suite. It encodes the isolation recipe Harper needs to run throwaway instances: + * + * - HOME points at a scratch dir so Harper can't find (or clobber) a real installation's + * boot-properties file, guaranteeing a fresh, isolated install. + * - ROOTPATH lives under /tmp with a short name: Harper's operations server listens on a unix + * domain socket inside ROOTPATH, and socket paths are capped at ~104 chars on macOS. + * - A throwaway admin user is created from HDB_ADMIN_USERNAME/PASSWORD. + * - Harper runs in its own process group (detached) so cleanup can kill the whole tree. + * + * The Harper version under test is whatever the `harperBin` resolves to — set HARPER_BIN to a + * specific install to pin the version (see template.tests/e2e/run.js). + */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +/** The throwaway superuser created for every isolated instance. */ +export const DEFAULT_CREDENTIALS = { username: 'smoke-admin', password: 'smoke-password' }; + +/** Build a Basic auth header value for the given credentials. */ +export function authHeader(credentials = DEFAULT_CREDENTIALS) { + return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Boot Harper against the given app directory and wait until the HTTP server accepts + * connections. The instance is NOT yet guaranteed to accept authenticated requests — call + * `waitUntilReady(authGatedPath)` before exercising resources (see the note on that method). + * + * @param {object} options + * @param {string} options.appDir - Directory containing the app's config.yaml. + * @param {string} [options.harperBin] - Path to the harper CLI (defaults to HARPER_BIN or `harper`). + * @param {number} [options.httpPort] + * @param {number} [options.opsPort] + * @param {{username: string, password: string}} [options.credentials] + * @param {(chunk: string) => void} [options.onLog] - Receives Harper's stdout/stderr as it arrives. + * @returns {Promise<{ + * baseUrl: string, opsUrl: string, credentials: object, authorization: string, + * stop: () => void, waitUntilReady: (probePath: string, timeoutMs?: number) => Promise, + * tail: () => string, + * }>} + */ +export async function bootHarper({ + appDir, + harperBin = process.env.HARPER_BIN ?? 'harper', + httpPort = Number(process.env.SMOKE_HTTP_PORT ?? 19926), + opsPort = Number(process.env.SMOKE_OPS_PORT ?? 19925), + credentials = DEFAULT_CREDENTIALS, + onLog, +} = {}) { + if (!appDir || !fs.existsSync(path.join(appDir, 'config.yaml'))) { + throw new Error(`bootHarper: appDir must contain a config.yaml (got ${appDir})`); + } + + const scratchDir = fs.mkdtempSync('/tmp/harper-e2e-'); + const rootPath = path.join(scratchDir, 'hdb'); + const homeDir = path.join(scratchDir, 'home'); + fs.mkdirSync(rootPath); + fs.mkdirSync(homeDir); + + const baseUrl = `http://127.0.0.1:${httpPort}`; + const opsUrl = `http://127.0.0.1:${opsPort}`; + const authorization = authHeader(credentials); + + let output = ''; + const record = (chunk) => { + output += chunk; + onLog?.(String(chunk)); + }; + const tail = () => output.slice(-4000); + + // HARPER_BIN can be an installed executable (`harper` on PATH, or a package's .bin/harper) or + // a raw built entrypoint from a source build (dist/bin/harper.js), which has no guaranteed + // executable bit — launch the latter through node. + const [command, commandArgs] = harperBin.endsWith('.js') + ? [process.execPath, [harperBin, 'run', appDir]] + : [harperBin, ['run', appDir]]; + + const harper = spawn(command, commandArgs, { + env: { + ...process.env, + HOME: homeDir, + TC_AGREEMENT: 'yes', + HDB_ADMIN_USERNAME: credentials.username, + HDB_ADMIN_PASSWORD: credentials.password, + ROOTPATH: rootPath, + HTTP_PORT: String(httpPort), + OPERATIONSAPI_NETWORK_PORT: String(opsPort), + }, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + harper.stdout.on('data', record); + harper.stderr.on('data', record); + + let exited = false; + harper.on('exit', () => (exited = true)); + // Without an 'error' listener a failed spawn (e.g. harper not on PATH) throws unhandled and + // skips cleanup entirely. + harper.on('error', (error) => { + record(`\nFailed to spawn ${harperBin}: ${error.message}\n`); + exited = true; + }); + + function stop() { + try { + if (harper.pid) { + process.kill(-harper.pid, 'SIGTERM'); + } + } catch {} + try { + fs.rmSync(scratchDir, { recursive: true, force: true }); + } catch {} + } + + async function waitForHttp(timeoutMs = 180_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (exited) { + throw new Error(`Harper exited before the HTTP server came up. Output:\n${tail()}`); + } + try { + await fetch(baseUrl, { signal: AbortSignal.timeout(2000) }); + return; + } catch { + await sleep(1000); + } + } + throw new Error(`Harper HTTP server did not come up within ${timeoutMs}ms. Output:\n${tail()}`); + } + + /** + * Wait until an authenticated GET of `probePath` returns a 2xx. + * + * The HTTP port answers `/` (and returns 401 for auth-gated routes) before the user/role + * store finishes loading, so a plain response is not "ready" — a request that races install + * completion comes back 401. Poll an auth-gated path (a resource or exported table) until it + * succeeds. Passing a non-auth-gated path (like `/` on a template that serves a frontend + * without auth) would return immediately and defeat the purpose. + */ + async function waitUntilReady(probePath, timeoutMs = 90_000) { + const deadline = Date.now() + timeoutMs; + let last = 'no response'; + while (Date.now() < deadline) { + if (exited) { + throw new Error(`Harper exited before it was ready. Output:\n${tail()}`); + } + try { + const response = await fetch(baseUrl + probePath, { + headers: { Authorization: authorization, Accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + if (response.ok) { + return; + } + last = `GET ${probePath} -> ${response.status}`; + } catch (error) { + last = error.message; + } + await sleep(500); + } + throw new Error(`Harper was not ready within ${timeoutMs}ms (${last}). Output:\n${tail()}`); + } + + try { + await waitForHttp(); + } catch (error) { + // Don't leak the spawned (detached) Harper process if it never came up. + stop(); + throw error; + } + return { baseUrl, opsUrl, credentials, authorization, stop, waitUntilReady, tail }; +} diff --git a/template.tests/e2e/overlay.js b/template.tests/e2e/overlay.js new file mode 100644 index 0000000..c4a8508 --- /dev/null +++ b/template.tests/e2e/overlay.js @@ -0,0 +1,140 @@ +/** + * Applies e2e fixtures onto a freshly generated app (in a throwaway directory), so a run can + * exercise more of the app than the templates ship with. + * + * These fixtures live under template.tests/ — they are never in the published npm tarball (the + * package.json `files` allowlist ships only the `template-*` dirs, `lib/`, and `index.js`) and + * are only ever copied into throwaway generated copies, never into the `template-*` sources. So + * none of this can leak into a real user's scaffolded app. + * + * Two tiers: + * - shared: a schema (E2eWidget) + a custom resource (E2eEcho), applied to every template. + * This is the Harper API surface, which is framework-agnostic. + * - framework: a component that fetches E2eWidget and renders it, mounted into the app's entry. + * react/vue/vanilla only (SSR entries differ — see capabilities.js). + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { capabilitiesFor } from './capabilities.js'; + +const fixturesDir = path.resolve(fileURLToPath(import.meta.url), '..', 'fixtures'); + +/** + * @param {string} appDir - The generated app to overlay onto. + * @param {string} templateName - Canonical template name (e.g. 'react-ts'). + * @returns {ReturnType} + */ +export function applyOverlay(appDir, templateName) { + const cap = capabilitiesFor(templateName); + + // Shared: schema + custom resource. Resource extension tracks the template's jsResource glob. + copyInto( + path.join(fixturesDir, 'shared', 'schemas', 'e2e.graphql'), + path.join(appDir, 'schemas', 'e2e.graphql'), + ); + copyInto( + path.join(fixturesDir, 'shared', 'resources', 'e2eEcho.js'), + path.join(appDir, 'resources', `e2eEcho.${cap.ext}`), + ); + + // Framework: the browser component + a mount appended to the app's entry. + if (cap.browser) { + applyFrontendOverlay(appDir, cap); + } + + return cap; +} + +function applyFrontendOverlay(appDir, cap) { + if (cap.framework === 'react') { + const componentExt = cap.ext === 'ts' ? 'tsx' : 'jsx'; + copyInto( + path.join(fixturesDir, 'react', 'E2eWidgets.jsx'), + path.join(appDir, 'src', `E2eWidgets.${componentExt}`), + ); + // import is hoisted, so appending to the entry is legal ESM. Alias the react-dom import so + // it can't collide with the entry's existing `createRoot` import. + appendTo( + path.join(appDir, 'src', `main.${componentExt}`), + [ + '', + '// --- create-harper e2e overlay ---', + `import { E2eWidgets } from "./E2eWidgets.${componentExt}";`, + 'import { createRoot as e2eCreateRoot } from "react-dom/client";', + 'const e2eMount = document.createElement("div");', + 'document.body.appendChild(e2eMount);', + 'e2eCreateRoot(e2eMount).render();', + '', + ].join('\n'), + ); + return; + } + + if (cap.framework === 'vue') { + copyInto( + path.join(fixturesDir, 'vue', 'E2eWidgets.vue'), + path.join(appDir, 'src', 'E2eWidgets.vue'), + ); + appendTo( + path.join(appDir, 'src', `main.${cap.ext}`), + [ + '', + '// --- create-harper e2e overlay ---', + 'import E2eWidgets from "./E2eWidgets.vue";', + 'import { createApp as e2eCreateApp } from "vue";', + 'const e2eMount = document.createElement("div");', + 'document.body.appendChild(e2eMount);', + 'e2eCreateApp(E2eWidgets).mount(e2eMount);', + '', + ].join('\n'), + ); + return; + } + + // vanilla: the frontend is plain files served statically from web/. Append a fetch that + // builds the same data-testid structure the browser spec looks for. + appendTo( + path.join(appDir, 'web', 'index.js'), + [ + '', + '// --- create-harper e2e overlay ---', + '(async () => {', + '\tconst section = document.createElement("section");', + '\tsection.setAttribute("data-testid", "e2e-widgets");', + '\tconst list = document.createElement("ul");', + '\tlist.setAttribute("data-testid", "e2e-widget-list");', + '\tsection.appendChild(list);', + '\tdocument.body.appendChild(section);', + '\ttry {', + '\t\tconst response = await fetch("/E2eWidget/", { headers: { Accept: "application/json" } });', + '\t\tconst data = await response.json();', + '\t\tfor (const widget of (Array.isArray(data) ? data : [])) {', + '\t\t\tconst item = document.createElement("li");', + '\t\t\titem.setAttribute("data-testid", "e2e-widget-item");', + '\t\t\titem.textContent = widget.name;', + '\t\t\tlist.appendChild(item);', + '\t\t}', + '\t} catch (cause) {', + '\t\tconst problem = document.createElement("p");', + '\t\tproblem.setAttribute("data-testid", "e2e-error");', + '\t\tproblem.textContent = String(cause);', + '\t\tsection.appendChild(problem);', + '\t}', + '})();', + '', + ].join('\n'), + ); +} + +function copyInto(from, to) { + fs.mkdirSync(path.dirname(to), { recursive: true }); + fs.copyFileSync(from, to); +} + +function appendTo(file, text) { + if (!fs.existsSync(file)) { + throw new Error(`overlay: expected entry file not found: ${file}`); + } + fs.appendFileSync(file, text); +} diff --git a/template.tests/e2e/playwright.config.js b/template.tests/e2e/playwright.config.js new file mode 100644 index 0000000..ebec453 --- /dev/null +++ b/template.tests/e2e/playwright.config.js @@ -0,0 +1,36 @@ +import { defineConfig } from '@playwright/test'; +import os from 'node:os'; +import path from 'node:path'; +import { authHeader } from './harper.js'; + +const httpPort = Number(process.env.SMOKE_HTTP_PORT ?? 19926); + +/** + * The e2e run is driven by template.tests/e2e/run.js, which generates an app, overlays fixtures, + * installs + builds it, then invokes Playwright with the app details in the environment: + * + * E2E_APP_DIR the generated app to boot Harper against (required) + * E2E_TEMPLATE the template name (for reporting) + * E2E_BROWSER '1' when a frontend component overlay was applied (gates the browser spec) + * HARPER_BIN the harper CLI to run — this is how the Harper version under test is pinned + * + * globalSetup boots one shared Harper instance and seeds a known record; specs run serially + * against it (workers: 1) so the CRUD lifecycle can't race the seeded browser scenario. + */ +export default defineConfig({ + testDir: './specs', + globalSetup: './globalSetup.js', + outputDir: path.join(os.tmpdir(), 'cha-e2e-artifacts'), + timeout: 30_000, + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + reporter: process.env.CI ? [['github'], ['list']] : [['list']], + use: { + baseURL: `http://127.0.0.1:${httpPort}`, + // Every request (API fetches from specs and the browser alike) authenticates as the + // throwaway superuser, so scenarios don't each have to log in. + extraHTTPHeaders: { Authorization: authHeader() }, + trace: 'retain-on-failure', + }, +}); diff --git a/template.tests/e2e/run.js b/template.tests/e2e/run.js new file mode 100644 index 0000000..09b47ef --- /dev/null +++ b/template.tests/e2e/run.js @@ -0,0 +1,194 @@ +#!/usr/bin/env node +/** + * End-to-end runner for a single template against a chosen Harper version. + * + * One code path for both local dev and CI: + * 1. Generate the app with the real create-harper CLI (into a throwaway temp dir). + * 2. Overlay e2e fixtures (schema + custom resource + a frontend component). + * 3. npm install the app. + * 4. Resolve the Harper CLI (how the version under test is pinned; nothing is installed + * globally), in precedence order: + * - HARPER_BIN if set — reuse an existing install/build. + * - --harper-ref — clone harperfast/harper, npm install + build from + * source, and use dist/bin/harper.js. This runs an + * UNPUBLISHED Harper straight from a commit. + * - --harper (default latest) — npm install harper@ into an isolated prefix + * (any npm spec: latest, next, 5.2.0-beta.1, ...). + * 5. Build the app if it has a build script. + * 6. Run Playwright (boots Harper, seeds data, exercises the HTTP + browser scenarios). + * + * Usage: + * node template.tests/e2e/run.js --template react-ts [--harper next] [--keep] + * node template.tests/e2e/run.js --template react-ts --harper-ref 1e1edc6 # build from a commit + * HARPER_BIN=/path/to/harper node template.tests/e2e/run.js --template vue # reuse an install + */ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { templateNames } from '../../lib/constants/templates.js'; +import { applyOverlay } from './overlay.js'; + +const e2eDir = path.resolve(fileURLToPath(import.meta.url), '..'); +const repoRoot = path.resolve(e2eDir, '..', '..'); + +const options = parseArgs(process.argv.slice(2)); +if (!options.template || !templateNames.includes(options.template)) { + console.error( + `Usage: node template.tests/e2e/run.js --template <${ + templateNames.join(' | ') + }> [--harper | --harper-ref ] [--keep]`, + ); + process.exit(2); +} + +const httpPort = process.env.SMOKE_HTTP_PORT ?? '19926'; +const opsPort = process.env.SMOKE_OPS_PORT ?? '19925'; +const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cha-e2e-')); +const appDir = path.join(workDir, `e2e-${options.template}`); + +let harperPrefix; +let harperSrc; +let failed = false; +try { + // 1. Generate with the real CLI. + sh( + 'node', + [ + path.join(repoRoot, 'index.js'), + `e2e-${options.template}`, + '--template', + options.template, + '--no-interactive', + '--overwrite', + '--skip-install', + ], + workDir, + { CREATE_HARPER_SKIP_UPDATE: '1', _HARPER_TEST_CLI: '1' }, + ); + + // 2. Overlay fixtures. + const cap = applyOverlay(appDir, options.template); + console.log(`\nOverlay applied for ${options.template}: ${JSON.stringify(cap)}`); + + // 3. Install app dependencies. + sh('npm', ['install', '--no-audit', '--no-fund'], appDir); + + // 4. Resolve the Harper CLI (see the precedence order in the file header). + let harperBin = process.env.HARPER_BIN; + if (harperBin) { + console.log(`\nUsing HARPER_BIN=${harperBin}`); + } else if (options.harperRef) { + harperSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'cha-harper-src-')); + console.log(`\nBuilding Harper from ${options.harperRepo} @ ${options.harperRef}`); + sh('git', ['init', '-q'], harperSrc); + sh('git', ['remote', 'add', 'origin', options.harperRepo], harperSrc); + // A shallow fetch of the exact ref — GitHub allows fetching an arbitrary commit sha, so + // this works for a sha, branch, or tag without cloning the whole history. + sh('git', ['fetch', '--depth', '1', 'origin', options.harperRef], harperSrc); + sh('git', ['checkout', '-q', 'FETCH_HEAD'], harperSrc); + // Full install (with scripts) so native addons (lmdb, argon2, ...) are built for running. + sh('npm', ['install', '--no-audit', '--no-fund'], harperSrc); + // The `build` script is `tsc`, used as a type-check, and Harper's `main` isn't always + // type-green — but tsc still EMITS dist/ on type errors. Harper's own release build + // (build-tools/build.sh) runs `npm run build || true` for exactly this reason, so tolerate + // a non-zero exit and instead require that the entrypoint was emitted. + sh('npm', ['run', 'build'], harperSrc, undefined, { check: false }); + harperBin = path.join(harperSrc, 'dist', 'bin', 'harper.js'); + if (!fs.existsSync(harperBin)) { + throw new Error( + `Harper build did not emit ${harperBin} — tsc may be set to noEmitOnError, or the bin layout changed.`, + ); + } + } else { + harperPrefix = fs.mkdtempSync(path.join(os.tmpdir(), 'cha-harper-')); + sh('npm', ['init', '-y'], harperPrefix); + sh('npm', ['install', '--no-audit', '--no-fund', `harper@${options.harper}`], harperPrefix); + harperBin = path.join(harperPrefix, 'node_modules', '.bin', 'harper'); + } + + // 5. Build if the template has a build step (Vite templates do; vanilla doesn't). + const pkg = JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf-8')); + if (pkg.scripts?.build) { + sh('npm', ['run', 'build'], appDir); + } + + // 6. Exercise it. + if (cap.browser) { + // --with-deps pulls the OS libraries Chromium needs on CI runners; skip it locally. + const withDeps = process.env.CI ? ['--with-deps'] : []; + sh('npx', ['playwright', 'install', ...withDeps, 'chromium'], repoRoot); + } + sh('npx', ['playwright', 'test', '--config', path.join(e2eDir, 'playwright.config.js')], repoRoot, { + E2E_APP_DIR: appDir, + E2E_TEMPLATE: options.template, + E2E_BROWSER: cap.browser ? '1' : '0', + E2E_SSR: cap.ssr ? '1' : '0', + HARPER_BIN: harperBin, + SMOKE_HTTP_PORT: httpPort, + SMOKE_OPS_PORT: opsPort, + }); +} catch (error) { + failed = true; + console.error(`\nE2E failed for ${options.template}: ${error.message}`); +} finally { + if (options.keep) { + console.log(`\n--keep: left the generated app at ${appDir}`); + } else { + rmrf(workDir); + rmrf(harperPrefix); + rmrf(harperSrc); + } +} + +process.exit(failed ? 1 : 0); + +function parseArgs(argv) { + const options = { + template: undefined, + harper: 'latest', + harperRef: undefined, + harperRepo: 'https://github.com/harperfast/harper.git', + keep: false, + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--template' || arg === '-t') { + options.template = argv[++i]; + } else if (arg === '--harper') { + options.harper = argv[++i]; + } else if (arg === '--harper-ref') { + options.harperRef = argv[++i]; + } else if (arg === '--harper-repo') { + options.harperRepo = argv[++i]; + } else if (arg === '--keep') { + options.keep = true; + } else if (!arg.startsWith('-') && !options.template) { + options.template = arg; + } + } + return options; +} + +function sh(command, args, cwd, env, { check = true } = {}) { + console.log(`\n$ ${command} ${args.join(' ')}${cwd ? ` (cwd: ${cwd})` : ''}`); + const result = spawnSync(command, args, { + cwd, + env: { ...process.env, ...env }, + stdio: 'inherit', + }); + if (check && result.status !== 0) { + throw new Error(`\`${command} ${args.join(' ')}\` exited with ${result.status ?? result.signal}`); + } + return result.status; +} + +function rmrf(dir) { + if (!dir) { + return; + } + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch {} +} diff --git a/template.tests/e2e/specs/browser.spec.js b/template.tests/e2e/specs/browser.spec.js new file mode 100644 index 0000000..8d1042f --- /dev/null +++ b/template.tests/e2e/specs/browser.spec.js @@ -0,0 +1,22 @@ +/** + * The end-to-end story: a real frontend component, running in a browser, fetches Harper's + * auto-REST API and renders the result. The overlay (template.tests/e2e/overlay.js) mounts a + * component that lists the E2eWidget table; globalSetup seeded one known record; this spec loads + * the built app and asserts that record shows up in the DOM. + * + * Gated on E2E_BROWSER, which run.js sets only for templates that got a frontend overlay + * (react/vue/vanilla SPAs). SSR templates are covered by the HTTP specs for now. + */ +import { expect, test } from '@playwright/test'; +import { SEED_WIDGET } from '../globalSetup.js'; + +test.describe('frontend consumes the Harper API', () => { + test.skip(process.env.E2E_BROWSER !== '1', 'no frontend component overlay for this template'); + + test('a component renders data fetched from the auto-REST API', async ({ page }) => { + await page.goto('/'); + + const seeded = page.getByTestId('e2e-widget-item').filter({ hasText: SEED_WIDGET.name }); + await expect(seeded.first()).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/template.tests/e2e/specs/crud.spec.js b/template.tests/e2e/specs/crud.spec.js new file mode 100644 index 0000000..20e315f --- /dev/null +++ b/template.tests/e2e/specs/crud.spec.js @@ -0,0 +1,46 @@ +/** + * The schema-driven auto-REST CRUD lifecycle — the Harper API surface a generated app relies on + * most, and the one most likely to shift between Harper versions. The exact status codes are + * asserted deliberately: this suite is a canary for Harper prereleases, so a changed contract + * (e.g. create no longer 201, delete no longer 200) should surface as a failure to triage. + * + * Verified against Harper 5.1.22: + * POST /E2eWidget/ -> 201, body is the new id + * GET /E2eWidget/{id} -> 200, the record + * PATCH /E2eWidget/{id} -> 204, no body + * GET /E2eWidget/?fiql -> 200, filtered array + * DELETE /E2eWidget/{id} -> 200, true + * GET /E2eWidget/{id} -> 404 once deleted + */ +import { expect, test } from '@playwright/test'; + +const id = 'crud-1'; +const json = { 'Content-Type': 'application/json' }; + +test('auto-REST CRUD lifecycle over the exported schema table', async ({ request }) => { + const created = await request.post('/E2eWidget/', { headers: json, data: { id, name: 'Gizmo', quantity: 7 } }); + expect(created.status(), 'POST create').toBe(201); + + const read = await request.get(`/E2eWidget/${id}`); + expect(read.status(), 'GET by id').toBe(200); + expect(await read.json()).toMatchObject({ id, name: 'Gizmo', quantity: 7 }); + + const patched = await request.patch(`/E2eWidget/${id}`, { headers: json, data: { quantity: 99 } }); + expect(patched.status(), 'PATCH update').toBe(204); + + const afterPatch = await request.get(`/E2eWidget/${id}`); + expect((await afterPatch.json()).quantity, 'PATCH persisted').toBe(99); + + // FIQL-style filtering via query parameters. + const filtered = await request.get('/E2eWidget/?quantity=gt=50'); + expect(filtered.status(), 'GET filtered list').toBe(200); + const rows = await filtered.json(); + expect(Array.isArray(rows), 'list is an array').toBe(true); + expect(rows.some((row) => row.id === id), 'filter matched the updated record').toBe(true); + + const deleted = await request.delete(`/E2eWidget/${id}`); + expect(deleted.status(), 'DELETE').toBe(200); + + const afterDelete = await request.get(`/E2eWidget/${id}`); + expect(afterDelete.status(), 'GET after delete').toBe(404); +}); diff --git a/template.tests/e2e/specs/frontend.spec.js b/template.tests/e2e/specs/frontend.spec.js new file mode 100644 index 0000000..3e2c0fe --- /dev/null +++ b/template.tests/e2e/specs/frontend.spec.js @@ -0,0 +1,22 @@ +/** + * SPA templates serve a frontend at `/` (built to dist/ for the Vite templates, static web/ for + * vanilla). A miss here means the static component isn't serving the app — or is swallowing + * requests it shouldn't. + * + * Gated off for SSR templates: their `static` handler sets `index: false` and `/` is served by + * the SSR entry-server, which behaves differently under `harper run` (local validation saw a 404). + * That serving path needs its own validation before this asserts — see the e2e README. SSR + * templates still run the framework-agnostic API specs (CRUD + custom resource). + */ +import { expect, test } from '@playwright/test'; + +test.describe('frontend serving', () => { + test.skip(process.env.E2E_SSR === '1', 'SSR index serving is validated separately (see e2e README)'); + + test('frontend index is served at /', async ({ request }) => { + const response = await request.get('/'); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toContain('text/html'); + expect((await response.text()).toLowerCase()).toContain(' { + const response = await request.get('/E2eEcho/'); + expect(response.status()).toBe(200); + expect(response.headers()['content-type'], 'a custom resource, not the static fallback') + .toContain('application/json'); + expect(await response.json()).toEqual({ echo: 'ok' }); +}); diff --git a/template.tests/runtimeSmoke.js b/template.tests/runtimeSmoke.js index f9bee62..411a1c3 100644 --- a/template.tests/runtimeSmoke.js +++ b/template.tests/runtimeSmoke.js @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Runtime smoke test for a generated template application. + * Runtime smoke test for a generated template application — the fast check that runs on every PR. * * Boots the app under a real Harper instance (isolated root, throwaway admin user) and verifies * the HTTP surface every template must provide: @@ -10,15 +10,18 @@ * which ran before the REST handler and answered resource GETs with index.html. * 2. The frontend is served — `GET /` returns the app's HTML. * + * For the fuller, version-parameterized end-to-end suite (schema CRUD, custom resources, and a + * frontend component that consumes the API in a browser), see template.tests/e2e/. + * * Usage: node template.tests/runtimeSmoke.js * * The app must already be installed (and built, for templates with a build step). Requires the * `harper` CLI on PATH (or set HARPER_BIN). POSIX only. Ports override via SMOKE_HTTP_PORT / * SMOKE_OPS_PORT. */ -import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { bootHarper } from './e2e/harper.js'; const appDir = process.argv[2] && path.resolve(process.argv[2]); if (!appDir || !fs.existsSync(path.join(appDir, 'config.yaml'))) { @@ -26,19 +29,6 @@ if (!appDir || !fs.existsSync(path.join(appDir, 'config.yaml'))) { process.exit(2); } -const httpPort = Number(process.env.SMOKE_HTTP_PORT ?? 19926); -const opsPort = Number(process.env.SMOKE_OPS_PORT ?? 19925); -const baseUrl = `http://127.0.0.1:${httpPort}`; -const credentials = `Basic ${Buffer.from('smoke-admin:smoke-password').toString('base64')}`; - -// Harper's operations server listens on a unix domain socket inside ROOTPATH, and socket paths -// are limited to ~104 characters on macOS — so keep the scratch root short and in /tmp. -const scratchDir = fs.mkdtempSync('/tmp/harper-smoke-'); -const rootPath = path.join(scratchDir, 'hdb'); -const homeDir = path.join(scratchDir, 'home'); -fs.mkdirSync(rootPath); -fs.mkdirSync(homeDir); - // The REST resource the test queries. Written into the app like a user would add one; the // extension must match the template's jsResource glob (`resources/*.ts` in TS templates). const isTypeScript = fs.readFileSync(path.join(appDir, 'config.yaml'), 'utf-8').includes('resources/*.ts'); @@ -57,54 +47,14 @@ fs.writeFileSync( `, ); -const harperBin = process.env.HARPER_BIN ?? 'harper'; -console.log(`Starting ${harperBin} run ${appDir} (root: ${rootPath}, port: ${httpPort})...`); -const harper = spawn(harperBin, ['run', appDir], { - env: { - ...process.env, - // HOME controls where Harper looks for the boot-properties file of an existing - // installation; pointing it at a scratch dir guarantees an isolated, fresh install. - HOME: homeDir, - TC_AGREEMENT: 'yes', - HDB_ADMIN_USERNAME: 'smoke-admin', - HDB_ADMIN_PASSWORD: 'smoke-password', - ROOTPATH: rootPath, - HTTP_PORT: String(httpPort), - OPERATIONSAPI_NETWORK_PORT: String(opsPort), - }, - // Own process group so cleanup can kill Harper's worker threads/children along with it. - detached: true, - stdio: ['ignore', 'pipe', 'pipe'], -}); - -let harperOutput = ''; -harper.stdout.on('data', (chunk) => (harperOutput += chunk)); -harper.stderr.on('data', (chunk) => (harperOutput += chunk)); -let harperExited = false; -harper.on('exit', () => (harperExited = true)); -// Without an 'error' listener a failed spawn (e.g. harper not on PATH) is an unhandled -// exception that skips cleanup entirely. -harper.on('error', (error) => { - harperOutput += `\nFailed to spawn ${harperBin}: ${error.message}\n`; - harperExited = true; -}); +const harper = await bootHarper({ appDir, onLog: (chunk) => process.stdout.write(chunk) }); function cleanup() { - try { - if (harper.pid) { - process.kill(-harper.pid, 'SIGTERM'); - } - } catch {} - try { - fs.rmSync(scratchDir, { recursive: true, force: true }); - } catch {} + harper.stop(); try { fs.rmSync(resourcePath, { force: true }); } catch {} } - -// Harper runs in its own process group (detached), so it outlives this process unless an -// interrupted run (Ctrl+C, CI cancellation) kills it explicitly. process.on('SIGINT', () => { cleanup(); process.exit(130); @@ -114,29 +64,13 @@ process.on('SIGTERM', () => { process.exit(143); }); -async function waitForServer(timeoutMs = 180_000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (harperExited) { - throw new Error(`Harper exited before the HTTP server came up. Output:\n${harperOutput.slice(-4000)}`); - } - try { - await fetch(baseUrl, { signal: AbortSignal.timeout(2000) }); - return; - } catch { - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } - throw new Error(`Harper HTTP server did not come up within ${timeoutMs}ms. Output:\n${harperOutput.slice(-4000)}`); -} - const failures = []; async function check(name, requestPath, assert) { let problem; try { - const response = await fetch(baseUrl + requestPath, { - headers: { Authorization: credentials }, + const response = await fetch(harper.baseUrl + requestPath, { + headers: { Authorization: harper.authorization }, signal: AbortSignal.timeout(10_000), }); const body = await response.text(); @@ -159,7 +93,9 @@ async function check(name, requestPath, assert) { } try { - await waitForServer(); + // The HTTP port answers before the auth store is ready; wait until an authenticated GET of + // the injected resource succeeds before asserting. + await harper.waitUntilReady('/SmokeTestResource/'); // The regression check: a GET for an exported resource must reach the REST handler and // return its JSON — not be swallowed by the static handler and answered with index.html. From 39a71f678c3389e5f7f7c8c105e639aaa2586b3e Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 21 Jul 2026 13:17:21 -0400 Subject: [PATCH 2/4] ci: temporarily run e2e on pull requests to validate on real runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow_dispatch can't be triggered until this workflow lands on the default branch, so add a temporary pull_request trigger (scoped to a single cheap cell: react-ts @ latest) to exercise the workflow on GitHub runners before merge. Revert before merging — PRs stay on the fast runtimeSmoke check by design. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/e2e.yaml | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 3b6d156..9659732 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -20,6 +20,12 @@ on: # a generated app. Opens an issue on failure (see the notify job). schedule: - cron: '0 7 * * *' + # TEMPORARY (remove before merge): validate this workflow on PR #124. Runs a single cheap cell + # (react-ts @ latest) — see the pull_request branch in the matrix step. workflow_dispatch is not + # dispatchable until this file lands on the default branch, so this is the only way to exercise + # it on real runners pre-merge. + pull_request: + branches: [main] concurrency: group: e2e-${{ github.ref }}-${{ github.event_name }} @@ -36,17 +42,22 @@ jobs: - id: set shell: bash run: | - # Templates: a manually chosen single template, otherwise a representative subset — - # one per frontend family plus one SSR variant. The package manager is irrelevant to - # Harper's API surface, so (unlike the PR integration test) this axis is npm-only. - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.template }}" != "all" ]; then + # Templates: on a PR (temporary), one cheap cell; a manually chosen single template; + # otherwise a representative subset — one per frontend family plus one SSR variant. The + # package manager is irrelevant to Harper's API surface, so (unlike the PR integration + # test) this axis is npm-only. + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo 'templates=["react-ts"]' >> "$GITHUB_OUTPUT" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.template }}" != "all" ]; then echo "templates=[\"${{ inputs.template }}\"]" >> "$GITHUB_OUTPUT" else echo 'templates=["vanilla-ts","react-ts","vue-ts","react-ts-ssr"]' >> "$GITHUB_OUTPUT" fi - # Versions: a single cell when building from a git ref (the ref is the only dimension), - # the dispatched npm spec, otherwise latest + next for the nightly canary. - if [ -n "${{ inputs.harper-ref }}" ]; then + # Versions: a PR (temporary) tests latest only; a git ref is a single (source) cell; a + # dispatched npm spec; otherwise latest + next for the nightly canary. + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo 'versions=["latest"]' >> "$GITHUB_OUTPUT" + elif [ -n "${{ inputs.harper-ref }}" ]; then echo 'versions=["(source)"]' >> "$GITHUB_OUTPUT" elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo 'versions=["${{ inputs.harper-version }}"]' >> "$GITHUB_OUTPUT" From 766d7cd36983e3d1d2e08870b774bce1bc59a838 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 21 Jul 2026 13:20:26 -0400 Subject: [PATCH 3/4] ci: remove temporary PR trigger for e2e Validated on real runners via PR #124 (react-ts @ latest, 4/4 green). The E2E workflow stays nightly + workflow_dispatch only; PRs keep using the fast runtimeSmoke check. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/e2e.yaml | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 9659732..3b6d156 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -20,12 +20,6 @@ on: # a generated app. Opens an issue on failure (see the notify job). schedule: - cron: '0 7 * * *' - # TEMPORARY (remove before merge): validate this workflow on PR #124. Runs a single cheap cell - # (react-ts @ latest) — see the pull_request branch in the matrix step. workflow_dispatch is not - # dispatchable until this file lands on the default branch, so this is the only way to exercise - # it on real runners pre-merge. - pull_request: - branches: [main] concurrency: group: e2e-${{ github.ref }}-${{ github.event_name }} @@ -42,22 +36,17 @@ jobs: - id: set shell: bash run: | - # Templates: on a PR (temporary), one cheap cell; a manually chosen single template; - # otherwise a representative subset — one per frontend family plus one SSR variant. The - # package manager is irrelevant to Harper's API surface, so (unlike the PR integration - # test) this axis is npm-only. - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo 'templates=["react-ts"]' >> "$GITHUB_OUTPUT" - elif [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.template }}" != "all" ]; then + # Templates: a manually chosen single template, otherwise a representative subset — + # one per frontend family plus one SSR variant. The package manager is irrelevant to + # Harper's API surface, so (unlike the PR integration test) this axis is npm-only. + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.template }}" != "all" ]; then echo "templates=[\"${{ inputs.template }}\"]" >> "$GITHUB_OUTPUT" else echo 'templates=["vanilla-ts","react-ts","vue-ts","react-ts-ssr"]' >> "$GITHUB_OUTPUT" fi - # Versions: a PR (temporary) tests latest only; a git ref is a single (source) cell; a - # dispatched npm spec; otherwise latest + next for the nightly canary. - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo 'versions=["latest"]' >> "$GITHUB_OUTPUT" - elif [ -n "${{ inputs.harper-ref }}" ]; then + # Versions: a single cell when building from a git ref (the ref is the only dimension), + # the dispatched npm spec, otherwise latest + next for the nightly canary. + if [ -n "${{ inputs.harper-ref }}" ]; then echo 'versions=["(source)"]' >> "$GITHUB_OUTPUT" elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo 'versions=["${{ inputs.harper-version }}"]' >> "$GITHUB_OUTPUT" From f30c2dbec95ca413fe882fdecfb8c70ba90a7c5d Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 21 Jul 2026 14:15:35 -0400 Subject: [PATCH 4/4] test: harden e2e cleanup and document Harper git refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review feedback: - bootHarper now installs SIGINT/SIGTERM handlers that kill the detached Harper (and run an onStop hook) so an interrupted run can't orphan a Harper process holding its ports; stop() deregisters them so it stays idempotent across boots. - runtimeSmoke delegates its injected-resource cleanup to onStop instead of maintaining duplicate signal handlers. - run.js cleans its temp dirs on SIGINT/SIGTERM, and --keep now retains only the generated app under test — the heavy Harper install/build dirs are always removed. - --harper-ref requires a full 40-char sha (git can't fetch a short sha directly); documented, with a preflight guard that rejects a short sha with a clear message. Branches/tags still work as refs. Co-Authored-By: Claude Opus 4.8 --- template.tests/e2e/README.md | 12 ++++++--- template.tests/e2e/harper.js | 21 +++++++++++++++ template.tests/e2e/run.js | 48 +++++++++++++++++++++++++++------- template.tests/runtimeSmoke.js | 28 +++++++++----------- 4 files changed, 79 insertions(+), 30 deletions(-) diff --git a/template.tests/e2e/README.md b/template.tests/e2e/README.md index 040f566..3fa51bf 100644 --- a/template.tests/e2e/README.md +++ b/template.tests/e2e/README.md @@ -18,10 +18,10 @@ npm run test:e2e -- --template react-ts npm run test:e2e -- --template react-ts --harper next npm run test:e2e -- --template vue-ts --harper 5.2.0-beta.1 -# Against an UNPUBLISHED Harper, built from a git commit / branch / tag: -npm run test:e2e -- --template react-ts --harper-ref 1e1edc6 +# Against an UNPUBLISHED Harper, built from a git branch / tag / full commit sha: npm run test:e2e -- --template react-ts --harper-ref my-feature-branch -npm run test:e2e -- --template react-ts --harper-ref abc123 --harper-repo https://github.com/me/harper.git +npm run test:e2e -- --template react-ts --harper-ref 1e1edc666ad373a0fbfec4df4d3f0e130be13529 +npm run test:e2e -- --template react-ts --harper-ref my-branch --harper-repo https://github.com/me/harper.git # Reuse a Harper you already have installed/built (skips resolution entirely): HARPER_BIN=/path/to/node_modules/.bin/harper npm run test:e2e -- --template vanilla-ts @@ -36,7 +36,11 @@ How Harper is resolved, in precedence order: source build's `dist/bin/harper.js`) is launched via `node`. - `--harper-ref ` — clone `harperfast/harper` (public; override with `--harper-repo`), `npm install` + `npm run build`, and run its `dist/bin/harper.js`. This tests - an unpublished Harper straight from a commit. Heaviest path (full install + `tsc` build). + an unpublished Harper straight from a commit. Heaviest path (full install + `tsc` build). A commit + ref must be a **full 40-char sha** (git can't fetch a short sha directly); branches and tags work + as-is. The `build` script is `tsc` and Harper's `main` isn't always type-green, so — like Harper's + own `build-tools/build.sh` (`npm run build || true`) — a non-zero build exit is tolerated as long + as `dist/bin/harper.js` was emitted. - `--harper ` (default `latest`) — `npm install harper@` into an isolated prefix. Any npm spec: `latest`, `next`, `5.2.0-beta.1`, a tarball, ... diff --git a/template.tests/e2e/harper.js b/template.tests/e2e/harper.js index 90e1d24..0df5ac2 100644 --- a/template.tests/e2e/harper.js +++ b/template.tests/e2e/harper.js @@ -40,6 +40,8 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); * @param {number} [options.opsPort] * @param {{username: string, password: string}} [options.credentials] * @param {(chunk: string) => void} [options.onLog] - Receives Harper's stdout/stderr as it arrives. + * @param {() => void} [options.onStop] - Extra caller cleanup run by stop() (and on interrupt), + * after Harper is killed and before the scratch dir is removed. * @returns {Promise<{ * baseUrl: string, opsUrl: string, credentials: object, authorization: string, * stop: () => void, waitUntilReady: (probePath: string, timeoutMs?: number) => Promise, @@ -53,6 +55,7 @@ export async function bootHarper({ opsPort = Number(process.env.SMOKE_OPS_PORT ?? 19925), credentials = DEFAULT_CREDENTIALS, onLog, + onStop, } = {}) { if (!appDir || !fs.existsSync(path.join(appDir, 'config.yaml'))) { throw new Error(`bootHarper: appDir must contain a config.yaml (got ${appDir})`); @@ -109,16 +112,34 @@ export async function bootHarper({ }); function stop() { + // Detach the interrupt handlers first so stop() is idempotent and repeated boots don't + // accumulate process listeners. + process.removeListener('SIGINT', onSignal); + process.removeListener('SIGTERM', onSignal); try { + // Harper runs in its own process group (detached), so it outlives this process unless + // killed explicitly — negative pid targets the whole group. if (harper.pid) { process.kill(-harper.pid, 'SIGTERM'); } } catch {} + try { + onStop?.(); + } catch {} try { fs.rmSync(scratchDir, { recursive: true, force: true }); } catch {} } + // An interrupted parent (Ctrl+C, CI cancellation) would otherwise leave the detached Harper + // orphaned and holding its ports. Kill it, then exit with the conventional signal code. + function onSignal(signal) { + stop(); + process.exit(signal === 'SIGINT' ? 130 : 143); + } + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + async function waitForHttp(timeoutMs = 180_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/template.tests/e2e/run.js b/template.tests/e2e/run.js index 09b47ef..46b2448 100644 --- a/template.tests/e2e/run.js +++ b/template.tests/e2e/run.js @@ -11,7 +11,9 @@ * - HARPER_BIN if set — reuse an existing install/build. * - --harper-ref — clone harperfast/harper, npm install + build from * source, and use dist/bin/harper.js. This runs an - * UNPUBLISHED Harper straight from a commit. + * UNPUBLISHED Harper straight from a commit. A commit + * must be a FULL 40-char sha (git can't fetch a short + * sha directly); branches and tags work as-is. * - --harper (default latest) — npm install harper@ into an isolated prefix * (any npm spec: latest, next, 5.2.0-beta.1, ...). * 5. Build the app if it has a build script. @@ -19,8 +21,8 @@ * * Usage: * node template.tests/e2e/run.js --template react-ts [--harper next] [--keep] - * node template.tests/e2e/run.js --template react-ts --harper-ref 1e1edc6 # build from a commit - * HARPER_BIN=/path/to/harper node template.tests/e2e/run.js --template vue # reuse an install + * node template.tests/e2e/run.js --template react-ts --harper-ref my-branch # build from source + * HARPER_BIN=/path/to/harper node template.tests/e2e/run.js --template vue # reuse an install */ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; @@ -51,6 +53,31 @@ const appDir = path.join(workDir, `e2e-${options.template}`); let harperPrefix; let harperSrc; let failed = false; + +/** + * Remove this run's temp dirs. --keep retains only the generated app under test (in workDir); the + * Harper install/build dirs are always removed — they're hundreds of MB and never the thing you + * want to inspect. + */ +function cleanUpTempDirs() { + if (options.keep) { + console.log(`\n--keep: left the generated app at ${appDir}`); + } else { + rmrf(workDir); + } + rmrf(harperPrefix); + rmrf(harperSrc); +} + +// finally handles normal and error exits; a bare interrupt would skip it, so clean up on signals +// too (Harper itself is killed by bootHarper inside the Playwright child). +for (const [signal, code] of [['SIGINT', 130], ['SIGTERM', 143]]) { + process.on(signal, () => { + cleanUpTempDirs(); + process.exit(code); + }); +} + try { // 1. Generate with the real CLI. sh( @@ -80,6 +107,13 @@ try { if (harperBin) { console.log(`\nUsing HARPER_BIN=${harperBin}`); } else if (options.harperRef) { + // git can't fetch a short sha directly from a remote, so fail early with a clear hint + // rather than a cryptic fetch error. Branch/tag names still work as refs. + if (/^[0-9a-f]{7,39}$/i.test(options.harperRef)) { + throw new Error( + `--harper-ref "${options.harperRef}" looks like a short commit sha; use a full 40-char sha, or a branch/tag name.`, + ); + } harperSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'cha-harper-src-')); console.log(`\nBuilding Harper from ${options.harperRepo} @ ${options.harperRef}`); sh('git', ['init', '-q'], harperSrc); @@ -133,13 +167,7 @@ try { failed = true; console.error(`\nE2E failed for ${options.template}: ${error.message}`); } finally { - if (options.keep) { - console.log(`\n--keep: left the generated app at ${appDir}`); - } else { - rmrf(workDir); - rmrf(harperPrefix); - rmrf(harperSrc); - } + cleanUpTempDirs(); } process.exit(failed ? 1 : 0); diff --git a/template.tests/runtimeSmoke.js b/template.tests/runtimeSmoke.js index 411a1c3..5879bb0 100644 --- a/template.tests/runtimeSmoke.js +++ b/template.tests/runtimeSmoke.js @@ -47,21 +47,16 @@ fs.writeFileSync( `, ); -const harper = await bootHarper({ appDir, onLog: (chunk) => process.stdout.write(chunk) }); - -function cleanup() { - harper.stop(); - try { - fs.rmSync(resourcePath, { force: true }); - } catch {} -} -process.on('SIGINT', () => { - cleanup(); - process.exit(130); -}); -process.on('SIGTERM', () => { - cleanup(); - process.exit(143); +// bootHarper installs SIGINT/SIGTERM handlers that run onStop and kill the detached instance, so +// removing the injected resource here covers the interrupt path too — no separate handlers needed. +const harper = await bootHarper({ + appDir, + onLog: (chunk) => process.stdout.write(chunk), + onStop: () => { + try { + fs.rmSync(resourcePath, { force: true }); + } catch {} + }, }); const failures = []; @@ -123,7 +118,8 @@ try { } catch (error) { failures.push(String(error?.message ?? error)); } finally { - cleanup(); + // stop() kills Harper, runs onStop (removes the injected resource), and clears its scratch dir. + harper.stop(); } if (failures.length > 0) {