From 60033081cdfdf4783feb040dd0e113dc33fba7e5 Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Mon, 15 Jun 2026 11:47:14 -0700 Subject: [PATCH 1/3] List supported frameworks in CLI scaffold flow --- .changeset/fix-start-then-setup-scaffold.md | 6 + .../consumer-journey.instructions.md | 9 +- .github/workflows/ci.yml | 67 ++- AGENTS.md | 16 +- README.md | 6 +- apps/cli-journey-e2e/AGENTS.md | 12 +- apps/cli-journey-e2e/README.md | 55 ++- apps/cli-journey-e2e/package.json | 2 +- apps/cli-journey-e2e/playwright.config.mts | 8 +- apps/cli-journey-e2e/scripts/frameworks.mjs | 78 ++++ .../scripts/frameworks.test.mjs | 36 ++ .../scripts/local-registry.mjs | 4 +- .../{prepare-next-app.mjs => prepare-app.mjs} | 97 ++-- ...next-app.test.mjs => prepare-app.test.mjs} | 32 +- apps/cli-journey-e2e/scripts/run-local.mjs | 421 +++++++++++------- apps/cli-journey-e2e/scripts/run-options.mjs | 74 +++ .../scripts/run-options.test.mjs | 45 ++ apps/cli-journey-e2e/src/contract.spec.ts | 5 +- apps/cli-journey-e2e/src/user-journey.spec.ts | 113 ++++- apps/cli/src/lib/orca/index.ts | 97 +++- .../src/lib/orca/patchers/rule/nuxt/README.md | 2 +- .../orca/patchers/rule/nuxt/nuxt-config.ts | 10 + apps/cli/tests/unit/commands/setup.test.ts | 101 +++++ apps/cli/tests/unit/lib/orca/index.test.ts | 7 +- .../patchers/rule/nuxt/nuxt-config.test.ts | 2 + packages/sdk-nuxt/package.json | 2 +- .../sdk-nuxt/src/runtime/server/handler.ts | 11 +- packages/sdk-nuxt/src/shims.d.ts | 9 + 28 files changed, 1003 insertions(+), 324 deletions(-) create mode 100644 .changeset/fix-start-then-setup-scaffold.md create mode 100644 apps/cli-journey-e2e/scripts/frameworks.mjs create mode 100644 apps/cli-journey-e2e/scripts/frameworks.test.mjs rename apps/cli-journey-e2e/scripts/{prepare-next-app.mjs => prepare-app.mjs} (81%) rename apps/cli-journey-e2e/scripts/{prepare-next-app.test.mjs => prepare-app.test.mjs} (87%) create mode 100644 apps/cli-journey-e2e/scripts/run-options.mjs create mode 100644 apps/cli-journey-e2e/scripts/run-options.test.mjs diff --git a/.changeset/fix-start-then-setup-scaffold.md b/.changeset/fix-start-then-setup-scaffold.md new file mode 100644 index 000000000..4d997e797 --- /dev/null +++ b/.changeset/fix-start-then-setup-scaffold.md @@ -0,0 +1,6 @@ +--- +"@zitadel/cli": patch +"@zitadel/sdk-nuxt": patch +--- + +Allow fresh app scaffolding after `zitadel start` creates local runtime ignore files, and load Nuxt runtime config through the Nuxt virtual imports module. diff --git a/.github/instructions/consumer-journey.instructions.md b/.github/instructions/consumer-journey.instructions.md index 352fdafd7..31e962415 100644 --- a/.github/instructions/consumer-journey.instructions.md +++ b/.github/instructions/consumer-journey.instructions.md @@ -10,7 +10,8 @@ gate, not as a demo-app e2e suite. - CI must consume the current workflow's GoReleaser image and npm package tarballs. Do not replace this with public npm packages for Zitadel packages. - The journey must exercise the customer local runtime flow through `npx`: - `doctor`, `start`, then `setup --framework next --server local` with + `doctor`, `start`, then + `setup --framework --server local` with `--non-interactive --json`. - Produce package artifacts with `corepack pnpm --dir pack` and keep tarball verification for required package presence plus unresolved @@ -19,8 +20,8 @@ gate, not as a demo-app e2e suite. design tokens must not be uploaded or published to Verdaccio. - Keep Verdaccio proxying npmjs for third-party dependencies while publishing Zitadel tarballs under both `alpha` and `latest`. -- Keep generated Next.js apps outside the repo and use `npm` inside the - generated app to match the documented consumer path. +- Keep generated apps outside the repo and use `npm` inside the generated app to + match the documented consumer path. - Preserve the CLI setup JSON contract: `--non-interactive --json` must parse from stdout and return `status: "ok"`. - Browser tests should run serially with one worker, use `localhost` for @@ -28,5 +29,5 @@ gate, not as a demo-app e2e suite. only a local debugging escape hatch. - Failure artifacts should include Playwright output/report, doctor/start/setup JSON and stderr, local runtime metadata/logs, metadata, generated app package - manifests, Verdaccio logs, and Next logs. Do not upload generated + manifests, Verdaccio logs, and generated app logs. Do not upload generated `node_modules` or `.next`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba2ca7958..e35680b4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -548,9 +548,14 @@ jobs: retention-days: 7 consumer-journey-e2e: + name: consumer-journey-e2e (${{ matrix.framework }}) runs-on: depot-ubuntu-24.04-4 timeout-minutes: 35 needs: [goreleaser-snapshot, npm-pack-smoke] + strategy: + fail-fast: false + matrix: + framework: [next, nuxt, react, vue, angular] steps: - uses: actions/checkout@v6 @@ -667,29 +672,43 @@ jobs: docker image inspect -f '{{.Os}}/{{.Architecture}}' "$(cat goreleaser-dist/nextgen-image.tag)" | grep -q '^linux/amd64$' echo "ZITADEL_LOCAL_IMAGE=$(cat goreleaser-dist/nextgen-image.tag)" >> "$GITHUB_ENV" - - name: Prepare fresh Next.js app + - name: Prepare fresh ${{ matrix.framework }} app id: prepare-app env: + JOURNEY_APP_URL: http://localhost:3000 + JOURNEY_FRAMEWORK: ${{ matrix.framework }} JOURNEY_REGISTRY_URL: http://127.0.0.1:4873 - JOURNEY_WORK_DIR: ${{ runner.temp }}/zitadel-cli-journey - run: node apps/cli-journey-e2e/scripts/prepare-next-app.mjs + JOURNEY_WORK_DIR: ${{ runner.temp }}/zitadel-cli-journey-${{ matrix.framework }} + run: node apps/cli-journey-e2e/scripts/prepare-app.mjs - - name: Start generated Next.js app + - name: Start generated ${{ matrix.framework }} app run: | cd "$JOURNEY_APP_DIR" - npm run dev -- --hostname localhost --port 3000 > "$RUNNER_TEMP/next-app.log" 2>&1 & - echo "NEXT_APP_PID=$!" >> "$GITHUB_ENV" + APP_LOG="$RUNNER_TEMP/${{ matrix.framework }}-app.log" + case "${{ matrix.framework }}" in + next) + npm run dev -- --hostname localhost --port 3000 > "$APP_LOG" 2>&1 & + ;; + *) + npm run dev -- --host localhost --port 3000 > "$APP_LOG" 2>&1 & + ;; + esac + echo "JOURNEY_APP_PID=$!" >> "$GITHUB_ENV" for _ in $(seq 1 90); do if curl -fsS http://localhost:3000/login >/dev/null; then exit 0 fi sleep 1 done - echo "::error::timed out waiting for generated Next.js app" - cat "$RUNNER_TEMP/next-app.log" + echo "::error::timed out waiting for generated ${{ matrix.framework }} app" + cat "$APP_LOG" exit 1 - name: Run consumer journey Playwright tests + env: + JOURNEY_FRAMEWORK: ${{ matrix.framework }} + JOURNEY_PLAYWRIGHT_OUTPUT_DIR: apps/cli-journey-e2e/test-output/playwright/${{ matrix.framework }}/output + JOURNEY_PLAYWRIGHT_REPORT_DIR: apps/cli-journey-e2e/test-output/playwright/${{ matrix.framework }}/report run: >- corepack pnpm --filter @zitadel/cli-journey-e2e exec playwright test --config playwright.config.mts @@ -697,8 +716,8 @@ jobs: - name: Collect diagnostics if: failure() run: | - DIAG_DIR="$RUNNER_TEMP/consumer-journey-diagnostics" - APP_DIR="$RUNNER_TEMP/zitadel-cli-journey/myapp" + DIAG_DIR="$RUNNER_TEMP/consumer-journey-diagnostics-${{ matrix.framework }}" + APP_DIR="$RUNNER_TEMP/zitadel-cli-journey-${{ matrix.framework }}/myapp" mkdir -p "$DIAG_DIR/generated-app" if [ -d "$APP_DIR" ]; then ( @@ -711,15 +730,15 @@ jobs: > "$DIAG_DIR/logs.json" 2> "$DIAG_DIR/logs.stderr.log" ) || true fi - cp "$RUNNER_TEMP/next-app.log" "$DIAG_DIR/" 2>/dev/null || true + cp "$RUNNER_TEMP/${{ matrix.framework }}-app.log" "$DIAG_DIR/" 2>/dev/null || true cp "$RUNNER_TEMP/verdaccio.log" "$DIAG_DIR/" 2>/dev/null || true - cp "$RUNNER_TEMP/zitadel-cli-journey/doctor.json" "$DIAG_DIR/" 2>/dev/null || true - cp "$RUNNER_TEMP/zitadel-cli-journey/doctor.stderr.log" "$DIAG_DIR/" 2>/dev/null || true - cp "$RUNNER_TEMP/zitadel-cli-journey/start.json" "$DIAG_DIR/" 2>/dev/null || true - cp "$RUNNER_TEMP/zitadel-cli-journey/start.stderr.log" "$DIAG_DIR/" 2>/dev/null || true - cp "$RUNNER_TEMP/zitadel-cli-journey/setup.json" "$DIAG_DIR/" 2>/dev/null || true - cp "$RUNNER_TEMP/zitadel-cli-journey/setup.stderr.log" "$DIAG_DIR/" 2>/dev/null || true - cp "$RUNNER_TEMP/zitadel-cli-journey/metadata.json" "$DIAG_DIR/" 2>/dev/null || true + cp "$RUNNER_TEMP/zitadel-cli-journey-${{ matrix.framework }}/doctor.json" "$DIAG_DIR/" 2>/dev/null || true + cp "$RUNNER_TEMP/zitadel-cli-journey-${{ matrix.framework }}/doctor.stderr.log" "$DIAG_DIR/" 2>/dev/null || true + cp "$RUNNER_TEMP/zitadel-cli-journey-${{ matrix.framework }}/start.json" "$DIAG_DIR/" 2>/dev/null || true + cp "$RUNNER_TEMP/zitadel-cli-journey-${{ matrix.framework }}/start.stderr.log" "$DIAG_DIR/" 2>/dev/null || true + cp "$RUNNER_TEMP/zitadel-cli-journey-${{ matrix.framework }}/setup.json" "$DIAG_DIR/" 2>/dev/null || true + cp "$RUNNER_TEMP/zitadel-cli-journey-${{ matrix.framework }}/setup.stderr.log" "$DIAG_DIR/" 2>/dev/null || true + cp "$RUNNER_TEMP/zitadel-cli-journey-${{ matrix.framework }}/metadata.json" "$DIAG_DIR/" 2>/dev/null || true cp "$APP_DIR/.zitadel/local/runtime.json" "$DIAG_DIR/runtime.json" 2>/dev/null || true cp "$APP_DIR/package.json" "$DIAG_DIR/generated-app/" 2>/dev/null || true cp "$APP_DIR/package-lock.json" "$DIAG_DIR/generated-app/" 2>/dev/null || true @@ -728,19 +747,19 @@ jobs: if: failure() uses: actions/upload-artifact@v7 with: - name: consumer-journey-diagnostics + name: consumer-journey-diagnostics-${{ matrix.framework }} path: | - apps/cli-journey-e2e/test-output/playwright/report/** - apps/cli-journey-e2e/test-output/playwright/output/** - ${{ runner.temp }}/consumer-journey-diagnostics/** + apps/cli-journey-e2e/test-output/playwright/${{ matrix.framework }}/report/** + apps/cli-journey-e2e/test-output/playwright/${{ matrix.framework }}/output/** + ${{ runner.temp }}/consumer-journey-diagnostics-${{ matrix.framework }}/** if-no-files-found: ignore retention-days: 7 - name: Tear down consumer journey services if: always() run: | - if [ -n "${NEXT_APP_PID:-}" ]; then kill "$NEXT_APP_PID" 2>/dev/null || true; fi - APP_DIR="$RUNNER_TEMP/zitadel-cli-journey/myapp" + if [ -n "${JOURNEY_APP_PID:-}" ]; then kill "$JOURNEY_APP_PID" 2>/dev/null || true; fi + APP_DIR="$RUNNER_TEMP/zitadel-cli-journey-${{ matrix.framework }}/myapp" if [ -d "$APP_DIR" ]; then ( cd "$APP_DIR" diff --git a/AGENTS.md b/AGENTS.md index 8da064d3a..076d54f1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,9 +45,10 @@ Secrets"). - `apps/demo-next-e2e/` and `apps/demo-nuxt-e2e/` are the Playwright projects that exercise each demo through real framework middleware against the api-mock TCP server. -- `apps/cli-journey-e2e/` contains the fresh Next.js consumer journey - Playwright project. It installs local package tarballs through a temporary - registry and verifies CLI setup plus real registration/login flows. +- `apps/cli-journey-e2e/` contains the fresh consumer journey Playwright + project for every CLI-supported framework. It installs local package tarballs + through a temporary registry and verifies CLI setup plus real + registration/login flows. - `packages/components/` contains shared Lit components. - `packages/sdk-core/`, `packages/sdk-next/`, `packages/sdk-nuxt/`, `packages/sdk-react/`, `packages/sdk-vue/`, and `packages/sdk-angular/` @@ -148,10 +149,11 @@ corepack pnpm run journey This runner requires Docker for Verdaccio and the CLI-managed local runtime. By default it builds a local runtime image, ensures the Playwright Chromium browsers are installed, builds and packs local npm packages with pnpm, creates -an empty app directory outside the repo, runs `npx @zitadel/cli@alpha doctor`, -`start`, and `setup --framework next --server local`, starts the generated app -on `localhost`, and runs Playwright with one worker. Use -`-- --image ` to reuse an existing local runtime image. +empty app directories outside the repo, runs `npx @zitadel/cli@alpha doctor`, +`start`, and `setup --framework --server local`, starts the generated apps +on `localhost`, and runs Playwright with one worker per framework journey. Use +`-- --framework next` to run one framework and `-- --image ` to +reuse an existing local runtime image. Use `corepack pnpm run journey` for deterministic CI-style proof of the fresh-app path. Use `corepack pnpm run cli -- ...` for manual browser or agent diff --git a/README.md b/README.md index 0c0985da9..d2db14f4c 100644 --- a/README.md +++ b/README.md @@ -153,8 +153,10 @@ corepack pnpm run journey This opt-in check ensures the Playwright Chromium browsers are installed, builds the local npm packages, publishes them to a temporary Verdaccio registry, runs `npx @zitadel/cli@alpha doctor`, `start`, and -`setup --framework next --server local` in an empty app directory, starts the -generated app, and verifies registration/login journeys. +`setup --framework --server local` in fresh app directories for every +supported framework, starts the generated apps, and verifies registration/login +journeys. Use `corepack pnpm run journey -- --framework next` to run only the +Next.js journey. Use `corepack pnpm run journey` for deterministic CI-style proof. Use `corepack pnpm run cli -- ...` when you want to drive the same local package diff --git a/apps/cli-journey-e2e/AGENTS.md b/apps/cli-journey-e2e/AGENTS.md index 4782606a6..ff7e36fe2 100644 --- a/apps/cli-journey-e2e/AGENTS.md +++ b/apps/cli-journey-e2e/AGENTS.md @@ -5,10 +5,10 @@ These instructions apply to `apps/cli-journey-e2e/**`. Defer to the root ## Purpose -This project protects the customer local setup journey. Tests must exercise a -fresh app directory that runs the CLI local runtime path (`doctor`, `start`, -`setup --server local`) before starting the generated Next.js app. It must not -test the checked-in demo apps. +This project protects the customer local setup journey across every supported +CLI framework. Tests must exercise a fresh app directory that runs the CLI local +runtime path (`doctor`, `start`, `setup --server local`) before starting the +generated app. It must not test the checked-in demo apps. ## Maintenance Rules @@ -20,8 +20,8 @@ test the checked-in demo apps. - CI must install Zitadel packages from current workflow tarballs through the temporary Verdaccio registry, not from public npm. - CI must run `npx @zitadel/cli@alpha doctor`, `start`, and - `setup --framework next --server local` from the fresh app directory with - `--non-interactive --json`. + `setup --framework --server local` from the + fresh app directory with `--non-interactive --json`. - Pack and upload only the public packages: `@zitadel/cli`, `@zitadel/api`, `@zitadel/components`, `@zitadel/sdk-core`, `@zitadel/sdk-next`, `@zitadel/sdk-nuxt`, diff --git a/apps/cli-journey-e2e/README.md b/apps/cli-journey-e2e/README.md index 1cd1f3abb..d94de9c94 100644 --- a/apps/cli-journey-e2e/README.md +++ b/apps/cli-journey-e2e/README.md @@ -4,9 +4,10 @@ Fresh-app Playwright coverage for the customer local setup journey. This project is private test infrastructure. It does not test the checked-in demo apps. Instead, it builds the current workspace packages, publishes packed -tarballs to a temporary registry, creates an empty app directory outside the -repo, runs the customer local CLI flow through `npx`, starts the generated app, -and verifies that a real user can register, log out, and log in again. +tarballs to a temporary registry, creates empty app directories outside the +repo, runs the customer local CLI flow through `npx`, starts the generated apps, +and verifies that real users can register, log out, and log in again across +Next, Nuxt, React, Vue, and Angular. ## Local runner @@ -14,8 +15,9 @@ and verifies that a real user can register, log out, and log in again. corepack pnpm run journey ``` -The default mode uses Docker for Verdaccio and for the CLI-managed local -runtime. It builds a local runtime image unless `--image` is provided: +The default mode runs the full framework matrix in parallel. It uses Docker for +Verdaccio and for the CLI-managed local runtimes, and builds a local runtime +image unless `--image` is provided: 1. Ensure the Playwright Chromium browsers are installed. 2. Build the public workspace packages. @@ -25,12 +27,12 @@ runtime. It builds a local runtime image unless `--image` is provided: 5. Start Verdaccio with npmjs proxying enabled. 6. Publish tarballs to Verdaccio with `alpha` and `latest` tags. 7. Build or use a local runtime Docker image for `zitadel start`. -8. Create an empty app directory in a temporary directory. +8. Create one empty app directory per selected framework in a temporary directory. 9. Run `npx @alpha doctor --non-interactive --json`. 10. Run `npx @alpha start --non-interactive --json`. -11. Run `npx @alpha setup --framework next --server local --non-interactive --json`. -12. Start the generated app on `localhost`. -13. Run the Playwright tests with one worker. +11. Run `npx @alpha setup --framework --server local --non-interactive --json`. +12. Start each generated app on `localhost`. +13. Run the Playwright tests with one worker per framework journey. ### Options @@ -38,8 +40,13 @@ runtime. It builds a local runtime image unless `--image` is provided: corepack pnpm run journey -- --keep corepack pnpm run journey -- --work-dir /tmp/zitadel-journey corepack pnpm run journey -- --image nextgen:local +corepack pnpm run journey -- --framework next +corepack pnpm run journey -- --concurrency 2 ``` +- `--framework ` runs one framework (`next`, `nuxt`, `react`, `vue`, or + `angular`) instead of the full matrix. +- `--concurrency ` controls local framework parallelism. The default is `5`. - `--image ` uses an existing local runtime image instead of building one. - `--keep` keeps the temporary work directory after success. @@ -48,29 +55,33 @@ corepack pnpm run journey -- --image nextgen:local Useful environment overrides: - `JOURNEY_REGISTRY_PORT` -- `JOURNEY_APP_PORT` +- `JOURNEY_APP_PORT` for single-framework runs only +- `JOURNEY_ZITADEL_PORT` for single-framework runs only - `JOURNEY_ENABLE_PASSKEY=0` as a local-only escape hatch while debugging passkey setup. CI must run passkey coverage. ## CI gate -The `consumer-journey-e2e` workflow job does not use public Zitadel packages or -GHCR images. It downloads the GoReleaser snapshot image and the public npm -package tarballs produced by the same workflow, publishes those tarballs to -Verdaccio, points `ZITADEL_LOCAL_IMAGE` at the loaded image, runs the same -`npx` local setup flow, and runs the same Playwright project against the -generated app. Private support packages such as design tokens are bundled into -the public packages that need them and must not be uploaded or published. +The `consumer-journey-e2e` workflow job runs as a framework matrix. Each matrix +leg does not use public Zitadel packages or GHCR images. It downloads the +GoReleaser snapshot image and the public npm package tarballs produced by the +same workflow, publishes those tarballs to Verdaccio, points +`ZITADEL_LOCAL_IMAGE` at the loaded image, runs the same `npx` local setup flow, +and runs the same Playwright project against the generated app. Private support +packages such as design tokens are bundled into the public packages that need +them and must not be uploaded or published. Failure diagnostics intentionally stay small: Playwright report/output, doctor/start/setup JSON and stderr, local runtime metadata/logs, metadata, -generated app `package.json` and `package-lock.json`, Verdaccio logs, and Next -logs. Do not upload generated app `node_modules` or `.next` directories. +generated app `package.json` and `package-lock.json`, Verdaccio logs, and +generated app logs. Do not upload generated app `node_modules` or framework +build directories. ## Coverage -The suite is serial and one-worker because every test shares the same fresh -backend and generated app instance. +Each framework suite is serial and one-worker because every test in that suite +shares the same fresh backend and generated app instance. Local all-framework +runs execute framework suites in parallel. - CLI local setup contract: doctor, start, and setup exit successfully, stdout parses as JSON, `status` is `ok`, the generated app depends on the local SDK @@ -79,5 +90,3 @@ backend and generated app instance. out, and log in again with password. - Passkey-only account: register with email/passkey, log out, and log in again with passkey. -- Password-plus-passkey account: register with password, accept passkey setup, - log out, log in with password, log out, and log in with passkey. diff --git a/apps/cli-journey-e2e/package.json b/apps/cli-journey-e2e/package.json index 5fc5994d0..caad12fbe 100644 --- a/apps/cli-journey-e2e/package.json +++ b/apps/cli-journey-e2e/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "test": "node --test scripts/*.test.mjs", - "prepare-app": "node scripts/prepare-next-app.mjs", + "prepare-app": "node scripts/prepare-app.mjs", "publish-tarballs": "node scripts/publish-tarballs.mjs", "verify-tarballs": "node scripts/verify-tarballs.mjs", "e2e-local": "node scripts/run-local.mjs", diff --git a/apps/cli-journey-e2e/playwright.config.mts b/apps/cli-journey-e2e/playwright.config.mts index efb6beb1a..7b11d52bf 100644 --- a/apps/cli-journey-e2e/playwright.config.mts +++ b/apps/cli-journey-e2e/playwright.config.mts @@ -2,18 +2,22 @@ import { nxE2EPreset } from "@nx/playwright/preset"; import { defineConfig, devices } from "@playwright/test"; const baseURL = process.env.JOURNEY_APP_URL ?? "http://localhost:3000"; +const outputDir = + process.env.JOURNEY_PLAYWRIGHT_OUTPUT_DIR ?? "./test-output/playwright/output"; +const reportDir = + process.env.JOURNEY_PLAYWRIGHT_REPORT_DIR ?? "./test-output/playwright/report"; export default defineConfig({ ...nxE2EPreset(import.meta.filename, { testDir: "./src" }), fullyParallel: false, workers: 1, - reporter: [["html", { outputFolder: "./test-output/playwright/report", open: "never" }]], + reporter: [["html", { outputFolder: reportDir, open: "never" }]], use: { baseURL, trace: "retain-on-failure", video: "retain-on-failure", }, - outputDir: "./test-output/playwright/output", + outputDir, projects: [ { name: "chromium", diff --git a/apps/cli-journey-e2e/scripts/frameworks.mjs b/apps/cli-journey-e2e/scripts/frameworks.mjs new file mode 100644 index 000000000..e8ab8b231 --- /dev/null +++ b/apps/cli-journey-e2e/scripts/frameworks.mjs @@ -0,0 +1,78 @@ +export const frameworkIds = ["next", "nuxt", "react", "vue", "angular"]; + +const viteDevArgs = (port) => [ + "run", + "dev", + "--", + "--host", + "localhost", + "--port", + String(port), +]; + +export const frameworks = [ + { + id: "next", + displayName: "Next.js", + sdkPackageDir: "packages/sdk-next", + readyPath: "/login", + expectsProtectedRouteRedirect: true, + devServerArgs: (port) => [ + "run", + "dev", + "--", + "--hostname", + "localhost", + "--port", + String(port), + ], + }, + { + id: "nuxt", + displayName: "Nuxt", + sdkPackageDir: "packages/sdk-nuxt", + readyPath: "/login", + expectsProtectedRouteRedirect: true, + devServerArgs: viteDevArgs, + }, + { + id: "react", + displayName: "React", + sdkPackageDir: "packages/sdk-react", + readyPath: "/login", + expectsProtectedRouteRedirect: false, + devServerArgs: viteDevArgs, + }, + { + id: "vue", + displayName: "Vue", + sdkPackageDir: "packages/sdk-vue", + readyPath: "/login", + expectsProtectedRouteRedirect: false, + devServerArgs: viteDevArgs, + }, + { + id: "angular", + displayName: "Angular", + sdkPackageDir: "packages/sdk-angular", + readyPath: "/login", + expectsProtectedRouteRedirect: false, + devServerArgs: viteDevArgs, + }, +]; + +export function frameworkForId(id) { + const framework = frameworks.find((candidate) => candidate.id === id); + if (!framework) { + throw new Error(`unsupported journey framework "${id}". Expected one of: ${frameworkIds.join(", ")}`); + } + return framework; +} + +export function appPortFromUrl(appUrl) { + const url = new URL(appUrl); + if (url.port) { + return Number(url.port); + } + return url.protocol === "https:" ? 443 : 80; +} diff --git a/apps/cli-journey-e2e/scripts/frameworks.test.mjs b/apps/cli-journey-e2e/scripts/frameworks.test.mjs new file mode 100644 index 000000000..05201ba3f --- /dev/null +++ b/apps/cli-journey-e2e/scripts/frameworks.test.mjs @@ -0,0 +1,36 @@ +/* oxlint-disable playwright/expect-expect */ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { appPortFromUrl, frameworkForId, frameworkIds, frameworks } from "./frameworks.mjs"; + +test("framework registry lists every CLI journey target", () => { + assert.deepEqual( + frameworks.map((framework) => framework.id), + ["next", "nuxt", "react", "vue", "angular"], + ); + assert.deepEqual(frameworkIds, frameworks.map((framework) => framework.id)); + for (const framework of frameworks) { + assert.match(framework.sdkPackageDir, /^packages\/sdk-/); + assert.equal(framework.readyPath, "/login"); + assert.deepEqual(framework.devServerArgs(3100).slice(0, 3), ["run", "dev", "--"]); + } +}); + +test("framework registry captures protected-route expectations", () => { + assert.equal(frameworkForId("next").expectsProtectedRouteRedirect, true); + assert.equal(frameworkForId("nuxt").expectsProtectedRouteRedirect, true); + assert.equal(frameworkForId("react").expectsProtectedRouteRedirect, false); + assert.equal(frameworkForId("vue").expectsProtectedRouteRedirect, false); + assert.equal(frameworkForId("angular").expectsProtectedRouteRedirect, false); +}); + +test("frameworkForId rejects unsupported frameworks", () => { + assert.throws(() => frameworkForId("svelte"), /unsupported journey framework/); +}); + +test("appPortFromUrl derives explicit and default ports", () => { + assert.equal(appPortFromUrl("http://localhost:4321"), 4321); + assert.equal(appPortFromUrl("http://localhost"), 80); + assert.equal(appPortFromUrl("https://localhost"), 443); +}); diff --git a/apps/cli-journey-e2e/scripts/local-registry.mjs b/apps/cli-journey-e2e/scripts/local-registry.mjs index f81399bee..e7405c1fa 100644 --- a/apps/cli-journey-e2e/scripts/local-registry.mjs +++ b/apps/cli-journey-e2e/scripts/local-registry.mjs @@ -154,7 +154,9 @@ export async function waitForHttp(url, label, child, log = () => undefined) { log(`${label} is ready at ${url}`); return; } - lastError = new Error(`${url} returned ${response.status}`); + const body = await response.text().catch(() => ""); + const detail = body ? `: ${body.slice(0, 1000)}` : ""; + lastError = new Error(`${url} returned ${response.status}${detail}`); } catch (error) { lastError = error; } diff --git a/apps/cli-journey-e2e/scripts/prepare-next-app.mjs b/apps/cli-journey-e2e/scripts/prepare-app.mjs similarity index 81% rename from apps/cli-journey-e2e/scripts/prepare-next-app.mjs rename to apps/cli-journey-e2e/scripts/prepare-app.mjs index bba7ff3f9..e6505a8c5 100644 --- a/apps/cli-journey-e2e/scripts/prepare-next-app.mjs +++ b/apps/cli-journey-e2e/scripts/prepare-app.mjs @@ -4,20 +4,25 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; +import { appPortFromUrl, frameworkForId } from "./frameworks.mjs"; + const here = dirname(fileURLToPath(import.meta.url)); const defaultRepoRoot = resolve(here, "../../.."); const defaultRegistryUrl = "http://127.0.0.1:4873"; const defaultAppUrl = "http://localhost:3000"; -export async function prepareNextApp(options = {}) { +export async function prepareApp(options = {}) { const env = options.env ?? process.env; const repoRoot = options.repoRoot ?? defaultRepoRoot; + const framework = frameworkForId(options.framework ?? env.JOURNEY_FRAMEWORK ?? "next"); const outputDir = resolve( - env.JOURNEY_WORK_DIR ?? join(tmpdir(), `zitadel-cli-journey-${process.pid}-${Date.now()}`), + env.JOURNEY_WORK_DIR ?? + join(tmpdir(), `zitadel-cli-journey-${framework.id}-${process.pid}-${Date.now()}`), ); const appDir = resolve(env.JOURNEY_APP_DIR ?? join(outputDir, "myapp")); const registryUrl = env.JOURNEY_REGISTRY_URL ?? defaultRegistryUrl; const appUrl = env.JOURNEY_APP_URL ?? defaultAppUrl; + const appPort = appPortFromUrl(appUrl); const zitadelPort = optionalPort(env.JOURNEY_ZITADEL_PORT, "JOURNEY_ZITADEL_PORT"); const fs = { appendFile: options.appendFile ?? appendFile, @@ -28,9 +33,13 @@ export async function prepareNextApp(options = {}) { }; const runCaptureFn = options.runCapture ?? runCapture; const packageNameFn = - options.packageName ?? ((relativePath) => packageName(relativePath, { readFile: fs.readFile, repoRoot })); + options.packageName ?? + ((relativePath) => packageName(relativePath, { readFile: fs.readFile, repoRoot })); const cliPackage = env.JOURNEY_CLI_PACKAGE ?? (await packageNameFn("apps/cli")); - const sdkNextPackage = env.JOURNEY_SDK_NEXT_PACKAGE ?? (await packageNameFn("packages/sdk-next")); + const sdkPackage = + env.JOURNEY_SDK_PACKAGE ?? + env[`JOURNEY_SDK_${framework.id.toUpperCase()}_PACKAGE`] ?? + (await packageNameFn(framework.sdkPackageDir)); const npmEnv = npmEnvironment(env, registryUrl); await fs.rm(appDir, { recursive: true, force: true }); @@ -67,7 +76,15 @@ export async function prepareNextApp(options = {}) { outputDir, runCapture: runCaptureFn, step: "setup", - stepArgs: ["setup", "--framework", "next", "--server", "local"], + stepArgs: [ + "setup", + "--framework", + framework.id, + "--server", + "local", + "--dev-port", + String(appPort), + ], writeFile: fs.writeFile, }); } catch (error) { @@ -83,54 +100,39 @@ export async function prepareNextApp(options = {}) { } await assertNoNestedApp(appDir, fs.readFile); - - const appPackage = JSON.parse(await fs.readFile(join(appDir, "package.json"), "utf8")); - const dependencies = { - ...(appPackage.dependencies ?? {}), - ...(appPackage.devDependencies ?? {}), - }; - if (!dependencies[sdkNextPackage]) { - throw new Error(`generated package.json does not depend on ${sdkNextPackage}`); - } - - const packageLockPath = join(appDir, "package-lock.json"); - const packageLock = JSON.parse(await fs.readFile(packageLockPath, "utf8")); - const packageScope = sdkNextPackage.split("/")[0]; - const scopedNodeModulePrefix = `node_modules/${packageScope}/`; - const lockedZitadelPackages = Object.entries(packageLock.packages ?? {}).filter( - ([name]) => name.startsWith(scopedNodeModulePrefix), - ); - if (lockedZitadelPackages.length === 0) { - throw new Error(`package-lock.json does not contain ${packageScope} packages`); - } - for (const [name, entry] of lockedZitadelPackages) { - const resolved = entry?.resolved; - if (typeof resolved !== "string" || !resolved.startsWith(registryUrl)) { - throw new Error(`${name} resolved outside the temporary registry: ${resolved}`); - } - } + await assertLocalPackageResolution({ + appDir, + readFile: fs.readFile, + registryUrl, + sdkPackage, + }); const metadata = { appDir, appUrl, cliPackage, doctorPath: join(outputDir, "doctor.json"), + framework: framework.id, + frameworkDisplayName: framework.displayName, localRuntimeUrl: startJson?.data?.urls?.api ?? null, outputDir, registryUrl, runtimeMetadataPath: join(appDir, ".zitadel/local/runtime.json"), - sdkNextPackage, + sdkPackage, setupPath: join(outputDir, "setup.json"), setupServer: setupJson?.data?.server ?? null, startPath: join(outputDir, "start.json"), + expectsProtectedRouteRedirect: framework.expectsProtectedRouteRedirect, }; const metadataPath = join(outputDir, "metadata.json"); await fs.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); await exportEnv("JOURNEY_APP_DIR", appDir, fs.appendFile, env); await exportEnv("JOURNEY_APP_URL", appUrl, fs.appendFile, env); + await exportEnv("JOURNEY_FRAMEWORK", framework.id, fs.appendFile, env); await exportEnv("JOURNEY_OUTPUT_DIR", outputDir, fs.appendFile, env); await exportOutput("app_dir", appDir, fs.appendFile, env); + await exportOutput("framework", framework.id, fs.appendFile, env); await exportOutput("output_dir", outputDir, fs.appendFile, env); if (options.logMetadata !== false) { @@ -200,6 +202,35 @@ async function assertNoNestedApp(appDir, readFileFn) { throw new Error("setup scaffolded a nested myapp directory instead of using the app root"); } +async function assertLocalPackageResolution(input) { + const appPackage = JSON.parse(await input.readFile(join(input.appDir, "package.json"), "utf8")); + const dependencies = { + ...(appPackage.dependencies ?? {}), + ...(appPackage.devDependencies ?? {}), + }; + if (!dependencies[input.sdkPackage]) { + throw new Error(`generated package.json does not depend on ${input.sdkPackage}`); + } + + const packageLock = JSON.parse( + await input.readFile(join(input.appDir, "package-lock.json"), "utf8"), + ); + const packageScope = input.sdkPackage.split("/")[0]; + const scopedNodeModulePrefix = `node_modules/${packageScope}/`; + const lockedZitadelPackages = Object.entries(packageLock.packages ?? {}).filter(([name]) => + name.startsWith(scopedNodeModulePrefix), + ); + if (lockedZitadelPackages.length === 0) { + throw new Error(`package-lock.json does not contain ${packageScope} packages`); + } + for (const [name, entry] of lockedZitadelPackages) { + const resolved = entry?.resolved; + if (typeof resolved !== "string" || !resolved.startsWith(input.registryUrl)) { + throw new Error(`${name} resolved outside the temporary registry: ${resolved}`); + } + } +} + async function packageName(relativePath, options) { const pkg = JSON.parse( await options.readFile(join(options.repoRoot, relativePath, "package.json"), "utf8"), @@ -275,7 +306,7 @@ async function exportOutput(name, value, appendFileFn, env) { if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { - await prepareNextApp(); + await prepareApp(); } catch (error) { console.error(errorMessage(error)); process.exit(1); diff --git a/apps/cli-journey-e2e/scripts/prepare-next-app.test.mjs b/apps/cli-journey-e2e/scripts/prepare-app.test.mjs similarity index 87% rename from apps/cli-journey-e2e/scripts/prepare-next-app.test.mjs rename to apps/cli-journey-e2e/scripts/prepare-app.test.mjs index 41ed16ebf..906bc73d4 100644 --- a/apps/cli-journey-e2e/scripts/prepare-next-app.test.mjs +++ b/apps/cli-journey-e2e/scripts/prepare-app.test.mjs @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { prepareNextApp } from "./prepare-next-app.mjs"; +import { prepareApp } from "./prepare-app.mjs"; test("prepares the customer local setup journey in the app root", async () => { const workDir = await mkdtemp(join(tmpdir(), "zitadel-journey-prepare-test-")); @@ -14,11 +14,13 @@ test("prepares the customer local setup journey in the app root", async () => { const image = "ghcr.io/zitadel/nextgen:test"; try { - const metadata = await prepareNextApp({ + const metadata = await prepareApp({ env: { + JOURNEY_APP_URL: "http://localhost:3010", JOURNEY_CLI_PACKAGE: "@zitadel/cli", + JOURNEY_FRAMEWORK: "react", JOURNEY_REGISTRY_URL: registryUrl, - JOURNEY_SDK_NEXT_PACKAGE: "@zitadel/sdk-next", + JOURNEY_SDK_PACKAGE: "@zitadel/sdk-react", JOURNEY_WORK_DIR: workDir, JOURNEY_ZITADEL_PORT: "18080", ZITADEL_LOCAL_IMAGE: image, @@ -27,7 +29,7 @@ test("prepares the customer local setup journey in the app root", async () => { runCapture: async (command, args, options) => { calls.push({ command, args, cwd: options.cwd, env: options.env }); if (args.includes("setup")) { - await writeGeneratedApp(options.cwd, registryUrl); + await writeGeneratedApp(options.cwd, registryUrl, "@zitadel/sdk-react"); } if (args.includes("start")) { await mkdir(join(options.cwd, ".zitadel/local"), { recursive: true }); @@ -46,6 +48,8 @@ test("prepares the customer local setup journey in the app root", async () => { const appDir = join(workDir, "myapp"); assert.equal(metadata.appDir, appDir); + assert.equal(metadata.framework, "react"); + assert.equal(metadata.sdkPackage, "@zitadel/sdk-react"); assert.equal(metadata.localRuntimeUrl, "http://localhost:18080"); assert.deepEqual( calls.map((call) => call.args), @@ -73,9 +77,11 @@ test("prepares the customer local setup journey in the app root", async () => { "@zitadel/cli@alpha", "setup", "--framework", - "next", + "react", "--server", "local", + "--dev-port", + "3010", "--non-interactive", "--json", ], @@ -101,10 +107,10 @@ test("collects local runtime logs when a CLI step fails", async () => { try { await assert.rejects( - prepareNextApp({ + prepareApp({ env: { JOURNEY_CLI_PACKAGE: "@zitadel/cli", - JOURNEY_SDK_NEXT_PACKAGE: "@zitadel/sdk-next", + JOURNEY_SDK_PACKAGE: "@zitadel/sdk-next", JOURNEY_WORK_DIR: workDir, }, logMetadata: false, @@ -148,10 +154,10 @@ test("records log collection failures thrown as non-Error values", async () => { try { await assert.rejects( - prepareNextApp({ + prepareApp({ env: { JOURNEY_CLI_PACKAGE: "@zitadel/cli", - JOURNEY_SDK_NEXT_PACKAGE: "@zitadel/sdk-next", + JOURNEY_SDK_PACKAGE: "@zitadel/sdk-next", JOURNEY_WORK_DIR: workDir, }, logMetadata: false, @@ -184,18 +190,18 @@ test("records log collection failures thrown as non-Error values", async () => { } }); -async function writeGeneratedApp(appDir, registryUrl) { +async function writeGeneratedApp(appDir, registryUrl, sdkPackage) { await writeFile( join(appDir, "package.json"), - `${JSON.stringify({ dependencies: { "@zitadel/sdk-next": "alpha" } }, null, 2)}\n`, + `${JSON.stringify({ dependencies: { [sdkPackage]: "alpha" } }, null, 2)}\n`, ); await writeFile( join(appDir, "package-lock.json"), `${JSON.stringify( { packages: { - "node_modules/@zitadel/sdk-next": { - resolved: `${registryUrl}/@zitadel/sdk-next/-/sdk-next.tgz`, + [`node_modules/${sdkPackage}`]: { + resolved: `${registryUrl}/${sdkPackage}/-/${sdkPackage.split("/").at(-1)}.tgz`, }, }, }, diff --git a/apps/cli-journey-e2e/scripts/run-local.mjs b/apps/cli-journey-e2e/scripts/run-local.mjs index e5050354d..8f2965dc1 100644 --- a/apps/cli-journey-e2e/scripts/run-local.mjs +++ b/apps/cli-journey-e2e/scripts/run-local.mjs @@ -1,17 +1,12 @@ import { createWriteStream } from "node:fs"; -import { - cp, - mkdir, - mkdtemp, - rm, - writeFile, -} from "node:fs/promises"; +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import net from "node:net"; +import { frameworkForId } from "./frameworks.mjs"; import { composeArgs, localRegistryPaths, @@ -21,6 +16,7 @@ import { stopLocalRegistry, waitForHttp, } from "./local-registry.mjs"; +import { parseLocalJourneyArgs } from "./run-options.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const projectRoot = resolve(here, ".."); @@ -28,20 +24,21 @@ const repoRoot = resolve(projectRoot, "../.."); const composeFile = join(projectRoot, "docker-compose.local.yaml"); const options = parseArgsOrExit(process.argv.slice(2)); +if (options.help) { + printUsage(); + process.exit(0); +} + +const selectedFrameworks = options.frameworkIds.map(frameworkForId); const workDir = resolve( options.workDir || (await mkdtemp(join(tmpdir(), "zitadel-cli-journey-local-"))), ); const diagnosticsDir = join(workDir, "diagnostics"); -const appDir = join(workDir, "myapp"); const registryPaths = localRegistryPaths(workDir); -const nextLogPath = join(diagnosticsDir, "next-app.log"); const composeLogPath = join(diagnosticsDir, "compose.log"); const composeProjectName = `zitadel-journey-${process.pid}-${Date.now()}`; const registryPort = await resolvePort("JOURNEY_REGISTRY_PORT"); -const appPort = await resolvePort("JOURNEY_APP_PORT", 3000); -const zitadelPort = await resolvePort("JOURNEY_ZITADEL_PORT"); const registryUrl = `http://127.0.0.1:${registryPort}`; -const appUrl = `http://localhost:${appPort}`; const cliPackage = await packageName(repoRoot, "apps/cli"); const compose = { envPath: registryPaths.composeEnvPath, @@ -50,7 +47,10 @@ const compose = { repoRoot, }; const childProcesses = new Set(); +const frameworkContexts = []; +const usedPorts = new Set([registryPort]); let composeStarted = false; +let composeLogsCollected = false; let cleanupStarted = false; let success = false; let localRuntimeImage = process.env.ZITADEL_LOCAL_IMAGE || options.image; @@ -59,9 +59,11 @@ process.on("SIGINT", () => void handleSignal("SIGINT")); process.on("SIGTERM", () => void handleSignal("SIGTERM")); try { + assertMatrixPortsAreDynamic(selectedFrameworks); await mkdir(diagnosticsDir, { recursive: true }); log(`work dir: ${workDir}`); + log(`frameworks: ${selectedFrameworks.map((framework) => framework.id).join(", ")}`); await assertDockerAvailable(); await ensurePlaywrightBrowsers(); await prepareLocalRegistry({ @@ -84,59 +86,20 @@ try { localRuntimeImage = await buildJourneyRuntimeImage(); } - await run("node", ["apps/cli-journey-e2e/scripts/prepare-next-app.mjs"], { - env: { - ...process.env, - JOURNEY_APP_URL: appUrl, - JOURNEY_ZITADEL_PORT: String(zitadelPort), - JOURNEY_REGISTRY_URL: registryUrl, - JOURNEY_WORK_DIR: workDir, - NPM_CONFIG_USERCONFIG: registryPaths.npmrcPath, - ZITADEL_LOCAL_IMAGE: localRuntimeImage, - }, - }); - - const nextProcess = startChild("npm", [ - "run", - "dev", - "--", - "--hostname", - "localhost", - "--port", - String(appPort), - ], { - cwd: appDir, - env: process.env, - logFile: nextLogPath, - }); - await waitForHttp(`${appUrl}/login`, "generated Next.js app", nextProcess); + for (const framework of selectedFrameworks) { + frameworkContexts.push(await createFrameworkContext(framework)); + } - await run( - "corepack", - [ - "pnpm", - "--filter", - "@zitadel/cli-journey-e2e", - "exec", - "playwright", - "test", - "--config", - "playwright.config.mts", - ], - { - env: { - ...process.env, - JOURNEY_APP_DIR: appDir, - JOURNEY_APP_URL: appUrl, - JOURNEY_OUTPUT_DIR: workDir, - }, - }, + await runWithConcurrency( + frameworkContexts, + Math.min(options.concurrency, frameworkContexts.length), + runFrameworkJourney, ); success = true; - log("customer local setup journey passed"); + log("customer local setup journey matrix passed"); } catch (error) { - await collectDiagnostics(); + await collectComposeLogs(); console.error(""); console.error(`[journey-local] failed: ${errorMessage(error)}`); console.error(`[journey-local] diagnostics preserved in ${workDir}`); @@ -154,85 +117,175 @@ process.exit(process.exitCode ?? 0); function parseArgsOrExit(args) { try { - return parseArgs(args); + return parseLocalJourneyArgs(args); } catch (error) { console.error(`[journey-local] ${errorMessage(error)}`); process.exit(1); } } -function parseArgs(args) { - const parsed = { - image: "", - keep: false, - workDir: "", +function printUsage() { + console.log(`usage: node scripts/run-local.mjs [options] + +Options: + --framework Run one framework: next, nuxt, react, vue, or angular + --concurrency Number of framework journeys to run in parallel (default: 5) + --image Use an existing local runtime image instead of building one + --keep Keep the temp work directory after success + --work-dir Use an explicit work directory +`); +} + +function assertMatrixPortsAreDynamic(frameworksToRun) { + if (frameworksToRun.length <= 1) return; + const fixedPorts = ["JOURNEY_APP_PORT", "JOURNEY_ZITADEL_PORT"].filter( + (name) => process.env[name], + ); + if (fixedPorts.length === 0) return; + throw new Error( + [ + `Cannot run the framework matrix with fixed ${fixedPorts.join(" and ")}.`, + "Unset those variables so the runner can allocate one port per framework,", + "or pass --framework to run a single journey with fixed ports.", + ].join(" "), + ); +} + +async function createFrameworkContext(framework) { + const frameworkWorkDir = join(workDir, framework.id); + const appPort = await resolveFrameworkPort("JOURNEY_APP_PORT", 3000); + const zitadelPort = await resolveFrameworkPort("JOURNEY_ZITADEL_PORT"); + const appUrl = `http://localhost:${appPort}`; + const appDir = join(frameworkWorkDir, "myapp"); + const playwrightRoot = join(projectRoot, "test-output", "playwright", framework.id); + return { + appDir, + appPort, + appUrl, + diagnosticsDir: join(diagnosticsDir, framework.id), + framework, + frameworkWorkDir, + logPath: join(diagnosticsDir, framework.id, `${framework.id}-app.log`), + playwrightOutputDir: join(playwrightRoot, "output"), + playwrightReportDir: join(playwrightRoot, "report"), + zitadelPort, }; +} - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - switch (arg) { - case "--backend": { - readValue(args, ++index, arg); - throw new Error( - [ - "--backend was removed from the journey runner.", - "The journey now always exercises `npx @zitadel/cli@alpha start`.", - "Remove `--backend`, or pass `--image ` / set ZITADEL_LOCAL_IMAGE to choose the local runtime image.", - ].join(" "), - ); - } - case "--image": { - parsed.image = readValue(args, ++index, arg); - break; - } - case "--keep": { - parsed.keep = true; - break; - } - case "--work-dir": { - parsed.workDir = readValue(args, ++index, arg); - break; - } - case "--help": { - printUsage(); - process.exit(0); - break; - } - default: { - throw new Error(`unknown argument: ${arg}`); - } +async function resolveFrameworkPort(envName, preferred) { + const explicit = process.env[envName]; + if (explicit) { + const port = validatePortValue(explicit, envName); + if (usedPorts.has(port)) { + throw new Error(`${envName}=${port} is already reserved by another journey service`); } + usedPorts.add(port); + return port; + } + + if (preferred && !usedPorts.has(preferred) && (await canListen(preferred))) { + usedPorts.add(preferred); + return preferred; } - return parsed; + let port = await freePort(); + while (usedPorts.has(port)) { + port = await freePort(); + } + usedPorts.add(port); + return port; } -function readValue(args, index, flag) { - const value = args[index]; - if (!value || value.startsWith("--")) { - throw new Error(`${flag} requires a value`); +async function runFrameworkJourney(context) { + const { framework } = context; + try { + await mkdir(context.diagnosticsDir, { recursive: true }); + log(`[${framework.id}] preparing fresh ${framework.displayName} app`); + await run("node", ["apps/cli-journey-e2e/scripts/prepare-app.mjs"], { + env: { + ...process.env, + JOURNEY_APP_URL: context.appUrl, + JOURNEY_FRAMEWORK: framework.id, + JOURNEY_ZITADEL_PORT: String(context.zitadelPort), + JOURNEY_REGISTRY_URL: registryUrl, + JOURNEY_WORK_DIR: context.frameworkWorkDir, + NPM_CONFIG_USERCONFIG: registryPaths.npmrcPath, + ZITADEL_LOCAL_IMAGE: localRuntimeImage, + }, + }); + + log(`[${framework.id}] starting generated app at ${context.appUrl}`); + const appProcess = startChild("npm", framework.devServerArgs(context.appPort), { + cwd: context.appDir, + env: process.env, + logFile: context.logPath, + }); + context.appProcess = appProcess; + await waitForHttp( + `${context.appUrl}${framework.readyPath}`, + `generated ${framework.displayName} app`, + appProcess, + (message) => log(`[${framework.id}] ${message}`), + ); + + log(`[${framework.id}] running Playwright journey`); + await run( + "corepack", + [ + "pnpm", + "--filter", + "@zitadel/cli-journey-e2e", + "exec", + "playwright", + "test", + "--config", + "playwright.config.mts", + ], + { + env: { + ...process.env, + JOURNEY_APP_DIR: context.appDir, + JOURNEY_APP_URL: context.appUrl, + JOURNEY_FRAMEWORK: framework.id, + JOURNEY_OUTPUT_DIR: context.frameworkWorkDir, + JOURNEY_PLAYWRIGHT_OUTPUT_DIR: context.playwrightOutputDir, + JOURNEY_PLAYWRIGHT_REPORT_DIR: context.playwrightReportDir, + }, + }, + ); + log(`[${framework.id}] journey passed`); + } catch (error) { + await collectDiagnostics(context); + throw new Error(`${framework.id}: ${errorMessage(error)}`, { cause: error }); } - return value; } -function printUsage() { - console.log(`usage: node scripts/run-local.mjs [options] +async function runWithConcurrency(items, limit, worker) { + const errors = []; + let index = 0; + + async function runNext() { + while (index < items.length) { + const item = items[index]; + index += 1; + try { + await worker(item); + } catch (error) { + errors.push(error); + } + } + } -Options: - --image Use an existing local runtime image instead of building one - --keep Keep the temp work directory after success - --work-dir Use an explicit work directory -`); + await Promise.all(Array.from({ length: limit }, () => runNext())); + if (errors.length > 0) { + throw new Error(errors.map(errorMessage).join("\n")); + } } async function resolvePort(envName, fallback) { const value = process.env[envName]; if (value) { - const port = Number(value); - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - throw new Error(`${envName} must be a TCP port, got ${value}`); - } - return port; + return validatePortValue(value, envName); } if (fallback) { return fallback; @@ -240,6 +293,14 @@ async function resolvePort(envName, fallback) { return freePort(); } +function validatePortValue(value, name) { + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`${name} must be a TCP port, got ${value}`); + } + return port; +} + function freePort() { return new Promise((resolvePortPromise, reject) => { const server = net.createServer(); @@ -256,6 +317,17 @@ function freePort() { }); } +function canListen(port) { + return new Promise((resolveCanListen) => { + const server = net.createServer(); + server.unref(); + server.once("error", () => resolveCanListen(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolveCanListen(true)); + }); + }); +} + async function ensurePlaywrightBrowsers() { log("ensuring Playwright Chromium browsers are installed"); await run("corepack", [ @@ -402,76 +474,81 @@ function commandErrorDetail(error) { return (detailLines.length > 0 ? detailLines : lines).slice(-4).join("\n"); } -async function collectDiagnostics() { - await mkdir(diagnosticsDir, { recursive: true }); - await collectLocalRuntimeLogs(); - if (composeStarted) { - try { - const result = await runCapture( - "docker", - composeArgs(compose, ["logs"]), - ); - await writeFile(composeLogPath, `${result.stdout}${result.stderr}`); - } catch (error) { - await writeFile(composeLogPath, `failed to collect compose logs: ${errorMessage(error)}\n`); - } - } +async function collectDiagnostics(context) { + if (context.diagnosticsCollected) return; + context.diagnosticsCollected = true; - await copyIfExists(join(workDir, "doctor.json"), join(diagnosticsDir, "doctor.json")); + await mkdir(context.diagnosticsDir, { recursive: true }); + await collectLocalRuntimeLogs(context); + await copyIfExists(context.logPath, join(context.diagnosticsDir, `${context.framework.id}-app.log`)); + await copyIfExists(join(context.frameworkWorkDir, "doctor.json"), join(context.diagnosticsDir, "doctor.json")); await copyIfExists( - join(workDir, "doctor.stderr.log"), - join(diagnosticsDir, "doctor.stderr.log"), + join(context.frameworkWorkDir, "doctor.stderr.log"), + join(context.diagnosticsDir, "doctor.stderr.log"), ); - await copyIfExists(join(workDir, "start.json"), join(diagnosticsDir, "start.json")); + await copyIfExists(join(context.frameworkWorkDir, "start.json"), join(context.diagnosticsDir, "start.json")); await copyIfExists( - join(workDir, "start.stderr.log"), - join(diagnosticsDir, "start.stderr.log"), + join(context.frameworkWorkDir, "start.stderr.log"), + join(context.diagnosticsDir, "start.stderr.log"), ); - await copyIfExists(join(workDir, "setup.json"), join(diagnosticsDir, "setup.json")); + await copyIfExists(join(context.frameworkWorkDir, "setup.json"), join(context.diagnosticsDir, "setup.json")); await copyIfExists( - join(workDir, "setup.stderr.log"), - join(diagnosticsDir, "setup.stderr.log"), + join(context.frameworkWorkDir, "setup.stderr.log"), + join(context.diagnosticsDir, "setup.stderr.log"), ); - await copyIfExists(join(workDir, "metadata.json"), join(diagnosticsDir, "metadata.json")); - await copyIfExists(join(workDir, "logs.json"), join(diagnosticsDir, "logs.json")); - await copyIfExists(join(workDir, "logs.stderr.log"), join(diagnosticsDir, "logs.stderr.log")); + await copyIfExists(join(context.frameworkWorkDir, "metadata.json"), join(context.diagnosticsDir, "metadata.json")); + await copyIfExists(join(context.frameworkWorkDir, "logs.json"), join(context.diagnosticsDir, "logs.json")); await copyIfExists( - join(appDir, ".zitadel/local/runtime.json"), - join(diagnosticsDir, "runtime.json"), + join(context.frameworkWorkDir, "logs.stderr.log"), + join(context.diagnosticsDir, "logs.stderr.log"), ); - await mkdir(join(diagnosticsDir, "generated-app"), { recursive: true }); await copyIfExists( - join(appDir, "package.json"), - join(diagnosticsDir, "generated-app", "package.json"), + join(context.appDir, ".zitadel/local/runtime.json"), + join(context.diagnosticsDir, "runtime.json"), ); + await mkdir(join(context.diagnosticsDir, "generated-app"), { recursive: true }); await copyIfExists( - join(appDir, "package-lock.json"), - join(diagnosticsDir, "generated-app", "package-lock.json"), + join(context.appDir, "package.json"), + join(context.diagnosticsDir, "generated-app", "package.json"), ); await copyIfExists( - join(projectRoot, "test-output", "playwright"), - join(diagnosticsDir, "playwright"), + join(context.appDir, "package-lock.json"), + join(context.diagnosticsDir, "generated-app", "package-lock.json"), ); + await copyIfExists(context.playwrightReportDir, join(context.diagnosticsDir, "playwright-report")); + await copyIfExists(context.playwrightOutputDir, join(context.diagnosticsDir, "playwright-output")); } -async function collectLocalRuntimeLogs() { +async function collectComposeLogs() { + if (!composeStarted || composeLogsCollected) return; + composeLogsCollected = true; + await mkdir(diagnosticsDir, { recursive: true }); try { - const result = await runCapture( - "npx", - cliArgs(["logs", "--tail", "400"]), - { cwd: appDir, env: npxEnv() }, - ); - await writeFile(join(diagnosticsDir, "logs.json"), result.stdout); - await writeFile(join(diagnosticsDir, "logs.stderr.log"), result.stderr); + const result = await runCapture("docker", composeArgs(compose, ["logs"])); + await writeFile(composeLogPath, `${result.stdout}${result.stderr}`); + } catch (error) { + await writeFile(composeLogPath, `failed to collect compose logs: ${errorMessage(error)}\n`); + } +} + +async function collectLocalRuntimeLogs(context) { + try { + const result = await runCapture("npx", cliArgs(["logs", "--tail", "400"]), { + cwd: context.appDir, + env: npxEnv(context), + }); + await writeFile(join(context.diagnosticsDir, "logs.json"), result.stdout); + await writeFile(join(context.diagnosticsDir, "logs.stderr.log"), result.stderr); } catch (error) { await writeFile( - join(diagnosticsDir, "logs.stderr.log"), + join(context.diagnosticsDir, "logs.stderr.log"), `failed to collect local runtime logs: ${errorMessage(error)}\n`, ); } } async function copyIfExists(source, destination) { + if (resolve(source) === resolve(destination)) return; try { await cp(source, destination, { recursive: true }); } catch (error) { @@ -489,7 +566,7 @@ async function cleanup() { await stopChild(child); } - await resetLocalRuntime(); + await Promise.all(frameworkContexts.map(resetLocalRuntime)); if (composeStarted) { try { @@ -500,15 +577,16 @@ async function cleanup() { } } -async function resetLocalRuntime() { +async function resetLocalRuntime(context) { try { - await runCapture( - "npx", - cliArgs(["reset", "--force"]), - { cwd: appDir, env: npxEnv() }, - ); + await runCapture("npx", cliArgs(["reset", "--force"]), { + cwd: context.appDir, + env: npxEnv(context), + }); } catch (error) { - console.error(`[journey-local] local runtime reset failed: ${errorMessage(error)}`); + console.error( + `[journey-local] ${context.framework.id} local runtime reset failed: ${errorMessage(error)}`, + ); } } @@ -542,7 +620,8 @@ function waitForExit(child, timeoutMs) { async function handleSignal(signal) { console.error(`[journey-local] received ${signal}, cleaning up`); - await collectDiagnostics(); + await collectComposeLogs(); + await Promise.all(frameworkContexts.map(collectDiagnostics)); await cleanup(); process.exit(130); } @@ -551,7 +630,7 @@ function cliArgs(args) { return ["--yes", `${cliPackage}@alpha`, ...args, "--non-interactive", "--json"]; } -function npxEnv() { +function npxEnv(_context) { const env = npmEnvironment(process.env, registryUrl, registryPaths.npmrcPath); const image = localRuntimeImage || process.env.ZITADEL_LOCAL_IMAGE; if (image) { diff --git a/apps/cli-journey-e2e/scripts/run-options.mjs b/apps/cli-journey-e2e/scripts/run-options.mjs new file mode 100644 index 000000000..05bd537a1 --- /dev/null +++ b/apps/cli-journey-e2e/scripts/run-options.mjs @@ -0,0 +1,74 @@ +import { frameworkForId, frameworkIds } from "./frameworks.mjs"; + +export function parseLocalJourneyArgs(args) { + const parsed = { + concurrency: 5, + frameworkIds: [...frameworkIds], + image: "", + keep: false, + workDir: "", + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + switch (arg) { + case "--backend": { + readValue(args, ++index, arg); + throw new Error( + [ + "--backend was removed from the journey runner.", + "The journey now always exercises `npx @zitadel/cli@alpha start`.", + "Remove `--backend`, or pass `--image ` / set ZITADEL_LOCAL_IMAGE to choose the local runtime image.", + ].join(" "), + ); + } + case "--concurrency": { + parsed.concurrency = parseConcurrency(readValue(args, ++index, arg)); + break; + } + case "--framework": { + const frameworkId = readValue(args, ++index, arg); + frameworkForId(frameworkId); + parsed.frameworkIds = [frameworkId]; + break; + } + case "--image": { + parsed.image = readValue(args, ++index, arg); + break; + } + case "--keep": { + parsed.keep = true; + break; + } + case "--work-dir": { + parsed.workDir = readValue(args, ++index, arg); + break; + } + case "--help": { + parsed.help = true; + break; + } + default: { + throw new Error(`unknown argument: ${arg}`); + } + } + } + + return parsed; +} + +function parseConcurrency(value) { + const concurrency = Number(value); + if (!Number.isInteger(concurrency) || concurrency < 1) { + throw new Error(`--concurrency must be a positive integer, got ${value}`); + } + return concurrency; +} + +function readValue(args, index, flag) { + const value = args[index]; + if (!value || value.startsWith("--")) { + throw new Error(`${flag} requires a value`); + } + return value; +} diff --git a/apps/cli-journey-e2e/scripts/run-options.test.mjs b/apps/cli-journey-e2e/scripts/run-options.test.mjs new file mode 100644 index 000000000..615d3c5fc --- /dev/null +++ b/apps/cli-journey-e2e/scripts/run-options.test.mjs @@ -0,0 +1,45 @@ +/* oxlint-disable playwright/expect-expect */ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { parseLocalJourneyArgs } from "./run-options.mjs"; + +test("local journey defaults to the full framework matrix", () => { + assert.deepEqual(parseLocalJourneyArgs([]), { + concurrency: 5, + frameworkIds: ["next", "nuxt", "react", "vue", "angular"], + image: "", + keep: false, + workDir: "", + }); +}); + +test("local journey can select one framework and tune concurrency", () => { + assert.deepEqual( + parseLocalJourneyArgs([ + "--framework", + "vue", + "--concurrency", + "2", + "--image", + "nextgen:test", + "--keep", + "--work-dir", + "/tmp/journey", + ]), + { + concurrency: 2, + frameworkIds: ["vue"], + image: "nextgen:test", + keep: true, + workDir: "/tmp/journey", + }, + ); +}); + +test("local journey rejects invalid options", () => { + assert.throws(() => parseLocalJourneyArgs(["--framework", "svelte"]), /unsupported/); + assert.throws(() => parseLocalJourneyArgs(["--concurrency", "0"]), /positive integer/); + assert.throws(() => parseLocalJourneyArgs(["--image"]), /requires a value/); + assert.throws(() => parseLocalJourneyArgs(["--unknown"]), /unknown argument/); +}); diff --git a/apps/cli-journey-e2e/src/contract.spec.ts b/apps/cli-journey-e2e/src/contract.spec.ts index 362d8c4c9..397cfdf7b 100644 --- a/apps/cli-journey-e2e/src/contract.spec.ts +++ b/apps/cli-journey-e2e/src/contract.spec.ts @@ -14,11 +14,12 @@ test("setup completed and installed local registry packages", async () => { expect(setup.source).toEqual(expect.any(String)); const metadata = JSON.parse(await readFile(join(outputDir, "metadata.json"), "utf8")); + expect(setup.data.framework).toBe(metadata.framework); const packageJson = JSON.parse(await readFile(join(appDir, "package.json"), "utf8")); - expect(packageJson.dependencies?.[metadata.sdkNextPackage]).toBeTruthy(); + expect(packageJson.dependencies?.[metadata.sdkPackage]).toBeTruthy(); const packageLock = JSON.parse(await readFile(join(appDir, "package-lock.json"), "utf8")); - const packageScope = metadata.sdkNextPackage.split("/")[0]; + const packageScope = metadata.sdkPackage.split("/")[0]; const lockedPackages = Object.entries(packageLock.packages ?? {}).filter(([name]) => name.startsWith(`node_modules/${packageScope}/`), ); diff --git a/apps/cli-journey-e2e/src/user-journey.spec.ts b/apps/cli-journey-e2e/src/user-journey.spec.ts index 969d27a34..f60da8dab 100644 --- a/apps/cli-journey-e2e/src/user-journey.spec.ts +++ b/apps/cli-journey-e2e/src/user-journey.spec.ts @@ -1,15 +1,22 @@ +/* oxlint-disable playwright/expect-expect, playwright/no-conditional-in-test */ import { expect, test, type Locator, type Page } from "@playwright/test"; test.describe.configure({ mode: "serial" }); +test.setTimeout(60_000); -test("password-only registration, logout, and password login work in a fresh Next app", async ({ +const framework = process.env.JOURNEY_FRAMEWORK ?? "next"; +const expectsProtectedRouteRedirect = framework === "next" || framework === "nuxt"; + +test(`password-only registration, logout, and password login work in a fresh ${framework} app`, async ({ page, }) => { const email = uniqueEmail("password"); const password = "Correct-Horse-42!"; - await expectProtectedRouteToRedirect(page); - await page.goto("/login"); + if (expectsProtectedRouteRedirect) { + await expectProtectedRouteToRedirect(page); + } + await gotoLogin(page); await registerWithPassword(page, email, password); await expectSignedIn(page); await expectSessionCookie(page); @@ -21,14 +28,14 @@ test("password-only registration, logout, and password login work in a fresh Nex }); if (process.env.JOURNEY_ENABLE_PASSKEY !== "0") { - test("passkey-only registration, logout, and passkey login work in a fresh Next app", async ({ + test(`passkey-only registration, logout, and passkey login work in a fresh ${framework} app`, async ({ page, }) => { await enableVirtualAuthenticator(page); const email = uniqueEmail("passkey"); - await page.goto("/login"); + await gotoLogin(page); await registerWithPasskey(page, email); await expectSignedIn(page); await expectSessionCookie(page); @@ -46,6 +53,27 @@ async function expectProtectedRouteToRedirect(page: Page): Promise { await expect(page).toHaveURL(/\/login(?:\?|$)/); } +async function gotoLogin(page: Page): Promise { + const loginUrl = /\/login(?:\?|$)/; + if (loginUrl.test(page.url())) { + return; + } + + await page.waitForLoadState("load", { timeout: 5000 }).catch(() => undefined); + if (loginUrl.test(page.url())) { + return; + } + + await page.goto("/login").catch(async (error: unknown) => { + if (!isInterruptedByLoginNavigation(error) && !loginUrl.test(page.url())) { + throw error; + } + if (!loginUrl.test(page.url())) { + await page.waitForURL(loginUrl, { timeout: 5000 }); + } + }); +} + async function registerWithPassword( page: Page, email: string, @@ -58,9 +86,8 @@ async function registerWithPassword( await fillProfileFieldsIfVisible(page); await choosePasswordRegistration(page); await fillPassword(page, password); - // Registration completes directly — the default flow has no passkey - // upsell step; passkey registration is offered up front instead. await clickSubmit(page); + await skipPasskeyUpsellIfVisible(page); } async function loginWithPassword( @@ -68,7 +95,7 @@ async function loginWithPassword( email: string, password: string, ): Promise { - await page.goto("/login"); + await gotoLogin(page); await fillEmail(page, email); if (!(await isPasswordVisible(page))) { await clickSubmit(page); @@ -89,21 +116,51 @@ async function registerWithPasskey(page: Page, email: string): Promise { } async function loginWithPasskey(page: Page, email: string): Promise { - await page.goto("/login"); + await gotoLogin(page); await fillEmail(page, email); await clickAction(page, /sign in with.*passkey|passkey/i, ["passkey"]); } +async function skipPasskeyUpsellIfVisible(page: Page): Promise { + const skip = page.getByRole("button", { name: /skip for now/i }); + const nextState = await Promise.race([ + skip + .waitFor({ state: "visible", timeout: 30_000 }) + .then(() => "passkey-upsell" as const), + signedInLocator(page) + .first() + .waitFor({ state: "visible", timeout: 30_000 }) + .then(() => "signed-in" as const), + ]).catch(() => { + throw new Error( + `Timed out waiting for signed-in surface or passkey upsell; current URL: ${page.url()}`, + ); + }); + if (nextState === "passkey-upsell") { + await skip.click(); + } +} + async function expectSignedIn(page: Page): Promise { await page.waitForURL("**/profile", { timeout: 30_000 }); - await expect(page.getByRole("heading", { name: /signed in/i })).toBeVisible(); + await expect(signedInLocator(page).first()).toBeVisible({ timeout: 30_000 }); } async function expectSessionCookie(page: Page): Promise { + await expect + .poll( + async () => + Boolean( + (await page.context().cookies()).find( + (cookie) => cookie.name === "__nextgen_session" && cookie.value, + ), + ), + { timeout: 10_000 }, + ) + .toBe(true); const sessionCookie = (await page.context().cookies()).find( (cookie) => cookie.name === "__nextgen_session", ); - expect(sessionCookie?.value).toBeTruthy(); expect(sessionCookie?.httpOnly).toBe(true); } @@ -129,7 +186,10 @@ async function logout(page: Page): Promise { } await expect(logout.first()).toBeVisible({ timeout: 5000 }); await logout.first().click(); - await expect(page).toHaveURL(/\/login(?:\?|$)/); + const loggedOutUrl = expectsProtectedRouteRedirect + ? /\/login(?:\?|$)/ + : /\/(?:login)?(?:\?|$)/; + await expect(page).toHaveURL(loggedOutUrl); await expectSessionCleared(page); } @@ -193,10 +253,15 @@ async function isPasswordVisible(page: Page): Promise { } async function expectSessionCleared(page: Page): Promise { - const sessionCookie = (await page.context().cookies()).find( - (cookie) => cookie.name === "__nextgen_session", - ); - expect(sessionCookie).toBeUndefined(); + await expect + .poll( + async () => + (await page.context().cookies()).some( + (cookie) => cookie.name === "__nextgen_session", + ), + { timeout: 5000 }, + ) + .toBe(false); } async function clickSubmit(page: Page): Promise { @@ -228,6 +293,22 @@ function actionLocator(page: Page, actionName: string) { return page.locator(`zl-button[action="${actionName}"], [data-action="${actionName}"]`); } +function isInterruptedByLoginNavigation(error: unknown): boolean { + return ( + error instanceof Error && + error.message.includes('interrupted by another navigation to "') && + error.message.includes("/login") + ); +} + +function profileActionLocator(page: Page): Locator { + return logoutLocator(page).or(page.getByRole("button", { name: /open user menu/i })); +} + +function signedInLocator(page: Page): Locator { + return page.getByRole("heading", { name: /signed in/i }).or(profileActionLocator(page)); +} + function fieldControl(page: Page, fieldName: string, label: RegExp): Locator { return page .getByTestId(`zitadel-field-${fieldName}`) diff --git a/apps/cli/src/lib/orca/index.ts b/apps/cli/src/lib/orca/index.ts index 498455ae5..76f827a1f 100644 --- a/apps/cli/src/lib/orca/index.ts +++ b/apps/cli/src/lib/orca/index.ts @@ -17,11 +17,18 @@ export type FrameworkChoice = Readonly<{ id: string; displayName: string }>; export type ScaffoldTarget = Readonly<{ scaffoldable: boolean; + hasGitignore: boolean; hasRuntimeOnlyZitadel: boolean; reason?: string; entries: ReadonlyArray; }>; +type ScaffoldStash = Readonly<{ + gitignore?: string; + root: string; + zitadel?: string; +}>; + /** * Orchestrates the three per-framework strategies — detectors (recognise an * existing project and extract its facts), scaffolders (create a project), and @@ -107,11 +114,11 @@ export class Orca { details: { entries: target.entries }, }); } - const stash = target.hasRuntimeOnlyZitadel ? await stashRuntimeOnlyZitadel(cwd) : undefined; + const stash = await stashFreshScaffoldArtifacts(cwd, target); try { await this.scaffolderFor(framework).scaffold(cwd, framework); } finally { - await restoreRuntimeOnlyZitadel(cwd, stash); + await restoreFreshScaffoldArtifacts(cwd, stash); } return this.detect(cwd, framework); } @@ -167,6 +174,7 @@ export function createOrca(): Orca { export async function inspectScaffoldTarget(cwd: string): Promise { const entries = await readdir(cwd, { withFileTypes: true }); const names = entries.map((entry) => entry.name).sort(); + let hasGitignore = false; let hasRuntimeOnlyZitadel = false; for (const entry of entries) { @@ -174,11 +182,13 @@ export async function inspectScaffoldTarget(cwd: string): Promise { @@ -215,19 +227,54 @@ async function isRuntimeOnlyZitadelDir(path: string): Promise { return true; } -async function stashRuntimeOnlyZitadel(cwd: string): Promise { - const source = join(cwd, ".zitadel"); +async function stashFreshScaffoldArtifacts( + cwd: string, + target: ScaffoldTarget, +): Promise { + if (!target.hasGitignore && !target.hasRuntimeOnlyZitadel) { + return undefined; + } + const parent = dirname(cwd); - const prefix = `.${basename(cwd)}.zitadel-local-stash`; - const stash = join(parent, `${prefix}-${String(process.pid)}-${String(Date.now())}`); - await rename(source, stash); + const root = join( + parent, + `.${basename(cwd)}.fresh-scaffold-stash-${String(process.pid)}-${String(Date.now())}`, + ); + await mkdir(root, { mode: 0o700 }); + + const stash: { gitignore?: string; root: string; zitadel?: string } = { root }; + if (target.hasRuntimeOnlyZitadel) { + stash.zitadel = join(root, ".zitadel"); + await rename(join(cwd, ".zitadel"), stash.zitadel); + } + if (target.hasGitignore) { + stash.gitignore = join(root, ".gitignore"); + await rename(join(cwd, ".gitignore"), stash.gitignore); + } return stash; } +async function restoreFreshScaffoldArtifacts( + cwd: string, + stash: ScaffoldStash | undefined, +): Promise { + if (!stash) { + return; + } + + try { + await restoreRuntimeOnlyZitadel(cwd, stash.zitadel); + await restoreGitignore(cwd, stash.gitignore); + } finally { + await rm(stash.root, { recursive: true, force: true }); + } +} + async function restoreRuntimeOnlyZitadel(cwd: string, stash: string | undefined): Promise { if (!stash) { return; } + const target = join(cwd, ".zitadel"); try { await rename(stash, target); @@ -245,6 +292,40 @@ async function restoreRuntimeOnlyZitadel(cwd: string, stash: string | undefined) await appendGitignoreEntry(cwd, ".zitadel/local/"); } +async function restoreGitignore(cwd: string, stash: string | undefined): Promise { + if (!stash) { + return; + } + + const path = join(cwd, ".gitignore"); + const stashed = await readFile(stash, "utf8"); + let current = ""; + try { + current = await readFile(path, "utf8"); + } catch (error) { + if (!isErrno(error, "ENOENT")) { + throw error; + } + } + + const existingLines = new Set( + current + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean), + ); + const missingLines = stashed + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !existingLines.has(line)); + if (missingLines.length === 0) { + return; + } + + const prefix = current.length === 0 || current.endsWith("\n") ? "" : "\n"; + await writeFile(path, `${current}${prefix}${missingLines.join("\n")}\n`); +} + async function appendGitignoreEntry(cwd: string, entry: string): Promise { const path = join(cwd, ".gitignore"); let existing = ""; diff --git a/apps/cli/src/lib/orca/patchers/rule/nuxt/README.md b/apps/cli/src/lib/orca/patchers/rule/nuxt/README.md index 201b8f796..178bd30e5 100644 --- a/apps/cli/src/lib/orca/patchers/rule/nuxt/README.md +++ b/apps/cli/src/lib/orca/patchers/rule/nuxt/README.md @@ -8,7 +8,7 @@ the session server-side — here through the `@zitadel/sdk-nuxt` module. - `app.vue` — router root - `pages/login.vue`, `pages/register.vue`, `pages/profile.vue` — auth pages - `plugins/zitadel-components.client.ts`, `plugins/auth.server.ts` — widget + session plugins -- `nuxt.config.*` — registers `@zitadel/sdk-nuxt/module`, sets the login path, seeds `runtimeConfig` (backend URL, proxy path, project id), and adds the components to `build.transpile` (edits the first matching `nuxt.config.{ts,mts,js,mjs}`) +- `nuxt.config.*` — registers `@zitadel/sdk-nuxt/module`, sets the backend URL, login path, and `/profile` as a protected route, seeds `runtimeConfig` (backend URL, proxy path, project id), and adds the components to `build.transpile` (edits the first matching `nuxt.config.{ts,mts,js,mjs}`) - `.env.example`, `.env.local` — `NUXT_PUBLIC_ZITADEL_PROJECT_ID` (client-exposed), plus the shared `ZITADEL_*` keys the base patcher writes (`ZITADEL_PROJECT_ID`, `ZITADEL_ISSUER`, `ZITADEL_URL`, `ZITADEL_ENVIRONMENT`) — the Nuxt config reads diff --git a/apps/cli/src/lib/orca/patchers/rule/nuxt/nuxt-config.ts b/apps/cli/src/lib/orca/patchers/rule/nuxt/nuxt-config.ts index 57af1e9e4..d6ae2da4b 100644 --- a/apps/cli/src/lib/orca/patchers/rule/nuxt/nuxt-config.ts +++ b/apps/cli/src/lib/orca/patchers/rule/nuxt/nuxt-config.ts @@ -31,10 +31,20 @@ export function nuxtConfigEdit(opts: { let changed = ensureArrayItem(config, "modules", NUXT_MODULE); const nextgen = ensureEditableObject(config, "nextgen"); + if (nextgen.url === undefined) { + nextgen.url = builders.raw( + `process.env.ZITADEL_URL ?? ${JSON.stringify(opts.server)}`, + ); + changed = true; + } if (nextgen.loginPath === undefined) { nextgen.loginPath = "/login"; changed = true; } + if (nextgen.protectedRoutes === undefined) { + nextgen.protectedRoutes = ["/profile"]; + changed = true; + } const runtimeConfig = ensureEditableObject(config, "runtimeConfig"); if (runtimeConfig.zitadelUrl === undefined) { diff --git a/apps/cli/tests/unit/commands/setup.test.ts b/apps/cli/tests/unit/commands/setup.test.ts index 917e2e91b..d56171072 100644 --- a/apps/cli/tests/unit/commands/setup.test.ts +++ b/apps/cli/tests/unit/commands/setup.test.ts @@ -33,6 +33,82 @@ async function makeNextProject(): Promise { return cwd; } +const FRAMEWORK_FIXTURES = [ + { + framework: "next", + expectedFile: "app/login/page.tsx", + async create() { + return makeNextProject(); + }, + }, + { + framework: "nuxt", + expectedFile: "app/pages/login.vue", + async create() { + const cwd = await mkdtemp(join(tmpdir(), "zitadel-setup-nuxt-")); + tempDirs.push(cwd); + await mkdir(join(cwd, "app"), { recursive: true }); + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ name: "demo", dependencies: { nuxt: "^4.0.0" } }), + ); + await writeFile(join(cwd, "nuxt.config.ts"), "export default defineNuxtConfig({})\n"); + return cwd; + }, + }, + { + framework: "react", + expectedFile: "src/App.tsx", + async create() { + const cwd = await mkdtemp(join(tmpdir(), "zitadel-setup-react-")); + tempDirs.push(cwd); + await mkdir(join(cwd, "src"), { recursive: true }); + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ name: "demo", dependencies: { react: "^19.0.0", vite: "^7.0.0" } }), + ); + await writeFile(join(cwd, "vite.config.ts"), "export default {}\n"); + return cwd; + }, + }, + { + framework: "vue", + expectedFile: "src/App.vue", + async create() { + const cwd = await mkdtemp(join(tmpdir(), "zitadel-setup-vue-")); + tempDirs.push(cwd); + await mkdir(join(cwd, "src"), { recursive: true }); + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ name: "demo", dependencies: { vue: "^3.0.0", vite: "^7.0.0" } }), + ); + await writeFile(join(cwd, "vite.config.ts"), "export default {}\n"); + return cwd; + }, + }, + { + framework: "angular", + expectedFile: "src/app/app.ts", + async create() { + const cwd = await mkdtemp(join(tmpdir(), "zitadel-setup-angular-")); + tempDirs.push(cwd); + await mkdir(join(cwd, "src/app"), { recursive: true }); + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ name: "demo", dependencies: { "@angular/core": "^20.0.0" } }), + ); + await writeFile( + join(cwd, "angular.json"), + JSON.stringify({ + defaultProject: "demo", + projects: { demo: { architect: { serve: { options: {} } } } }, + }), + ); + return cwd; + }, + }, +] as const; + afterEach(async () => { while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -92,6 +168,31 @@ describe("setup command", () => { expect(json.data.next_commands).toEqual(["npm install", "npm run dev"]); }); + it.each(FRAMEWORK_FIXTURES)( + "dry-run setup supports $framework projects", + async ({ create, expectedFile, framework }) => { + const cwd = await create(); + + const res = await setup(cwd, ["--dry-run", "--skip-install", "--framework", framework]); + + expect(res.exitCode).toBe(0); + const json = parseJson(res.stdout) as { + status: string; + data: { + install: { status: string; reason: string }; + framework: string; + files_written: string[]; + next_commands: string[]; + }; + }; + expect(json.status).toBe("ok"); + expect(json.data.framework).toBe(framework); + expect(json.data.install).toMatchObject({ status: "skipped", reason: "dry-run" }); + expect(json.data.files_written).toContain(expectedFile); + expect(json.data.next_commands).toEqual(["npm install", "npm run dev"]); + }, + ); + it("errors in a non-interactive empty directory without --framework", async () => { const cwd = await mkdtemp(join(tmpdir(), "zitadel-setup-empty-")); tempDirs.push(cwd); diff --git a/apps/cli/tests/unit/lib/orca/index.test.ts b/apps/cli/tests/unit/lib/orca/index.test.ts index c3cb74062..af6304581 100644 --- a/apps/cli/tests/unit/lib/orca/index.test.ts +++ b/apps/cli/tests/unit/lib/orca/index.test.ts @@ -184,6 +184,9 @@ describe("Orca.scaffold", () => { await expect(stat(join(scaffoldCwd, ".zitadel"))).rejects.toMatchObject({ code: "ENOENT", }); + await expect(stat(join(scaffoldCwd, ".gitignore"))).rejects.toMatchObject({ + code: "ENOENT", + }); await writeFile(join(scaffoldCwd, ".gitignore"), "node_modules\n"); await writeFile(join(scaffoldCwd, "package.json"), "{}"); }, @@ -195,7 +198,9 @@ describe("Orca.scaffold", () => { await expect(readFile(join(cwd, ".zitadel/local/runtime.json"), "utf8")).resolves.toContain( "localhost", ); - await expect(readFile(join(cwd, ".gitignore"), "utf8")).resolves.toContain(".zitadel/local/"); + await expect(readFile(join(cwd, ".gitignore"), "utf8")).resolves.toBe( + "node_modules\n.zitadel/local/\n", + ); await expect(stat(join(cwd, "myapp"))).rejects.toMatchObject({ code: "ENOENT" }); }); diff --git a/apps/cli/tests/unit/lib/orca/patchers/rule/nuxt/nuxt-config.test.ts b/apps/cli/tests/unit/lib/orca/patchers/rule/nuxt/nuxt-config.test.ts index ca091d873..68eca76f1 100644 --- a/apps/cli/tests/unit/lib/orca/patchers/rule/nuxt/nuxt-config.test.ts +++ b/apps/cli/tests/unit/lib/orca/patchers/rule/nuxt/nuxt-config.test.ts @@ -8,7 +8,9 @@ describe("nuxtConfigEdit", () => { it("merges modules, nextgen, runtimeConfig, and build into an empty config", () => { const out = edit("export default defineNuxtConfig({})"); expect(out).toContain("@zitadel/sdk-nuxt/module"); + expect(out).toContain('url: process.env.ZITADEL_URL ?? "http://127.0.0.1:8080"'); expect(out).toContain('loginPath: "/login"'); + expect(out).toContain('protectedRoutes: ["/profile"]'); expect(out).toContain('nextgenProxyPath: "/__nextgen"'); expect(out).toContain("process.env.ZITADEL_URL"); expect(out).toContain("process.env.NUXT_PUBLIC_ZITADEL_PROJECT_ID"); diff --git a/packages/sdk-nuxt/package.json b/packages/sdk-nuxt/package.json index b8732a7c4..2f0b0c781 100644 --- a/packages/sdk-nuxt/package.json +++ b/packages/sdk-nuxt/package.json @@ -33,7 +33,7 @@ "access": "public" }, "scripts": { - "build": "tsup src/index.ts src/server.ts src/runtime/types.ts src/runtime/server/middleware.ts src/runtime/server/handler.ts --format esm --dts --clean && tsup src/runtime/plugin.ts src/runtime/composables/useAuth.ts src/module.ts --format esm --no-clean --external @nuxt/kit --external '#imports'", + "build": "tsup src/index.ts src/server.ts src/runtime/types.ts src/runtime/server/middleware.ts src/runtime/server/handler.ts --format esm --dts --clean --external '#imports' && tsup src/runtime/plugin.ts src/runtime/composables/useAuth.ts src/module.ts --format esm --no-clean --external @nuxt/kit --external '#imports'", "dev": "tsup src/index.ts src/server.ts --format esm --dts --watch", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", diff --git a/packages/sdk-nuxt/src/runtime/server/handler.ts b/packages/sdk-nuxt/src/runtime/server/handler.ts index 7919523d2..ac7c52669 100644 --- a/packages/sdk-nuxt/src/runtime/server/handler.ts +++ b/packages/sdk-nuxt/src/runtime/server/handler.ts @@ -1,11 +1,8 @@ +import { useRuntimeConfig } from '#imports'; + import { createNextgenMiddleware } from './middleware'; -/** - * `useRuntimeConfig` is auto-imported by Nitro when this file is bundled into - * the consumer's Nuxt application. The declaration here satisfies TypeScript - * during SDK compilation without introducing a hard runtime dependency. - */ -declare function useRuntimeConfig(): { +const config = useRuntimeConfig() as { nextgen?: { url?: string; issuerUrl?: string; @@ -14,8 +11,6 @@ declare function useRuntimeConfig(): { }; }; -const config = useRuntimeConfig(); - /** * Default Nitro/H3 event handler registered by the `@zitadel/sdk-nuxt` module * via {@link addServerHandler}. Reads middleware options from Nuxt runtime diff --git a/packages/sdk-nuxt/src/shims.d.ts b/packages/sdk-nuxt/src/shims.d.ts index d695604a5..b72e17ced 100644 --- a/packages/sdk-nuxt/src/shims.d.ts +++ b/packages/sdk-nuxt/src/shims.d.ts @@ -15,4 +15,13 @@ declare module '#imports' { export function useRequestEvent(): | { context: Record } | undefined; + export function useRuntimeConfig(): { + nextgen?: { + url?: string; + issuerUrl?: string; + loginPath?: string; + protectedRoutes?: string[]; + }; + public: Record; + }; } From e8d8c5d4c987222c90a86bb5b7c745520950edb8 Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Mon, 15 Jun 2026 13:08:53 -0700 Subject: [PATCH 2/3] Harden cli journey navigation checks --- apps/cli-journey-e2e/src/user-journey.spec.ts | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/apps/cli-journey-e2e/src/user-journey.spec.ts b/apps/cli-journey-e2e/src/user-journey.spec.ts index f60da8dab..26fdefdff 100644 --- a/apps/cli-journey-e2e/src/user-journey.spec.ts +++ b/apps/cli-journey-e2e/src/user-journey.spec.ts @@ -6,6 +6,8 @@ test.setTimeout(60_000); const framework = process.env.JOURNEY_FRAMEWORK ?? "next"; const expectsProtectedRouteRedirect = framework === "next" || framework === "nuxt"; +const loginUrl = /\/login(?:[/?#]|$)/; +const profileUrl = /\/profile(?:[/?#]|$)/; test(`password-only registration, logout, and password login work in a fresh ${framework} app`, async ({ page, @@ -50,11 +52,10 @@ if (process.env.JOURNEY_ENABLE_PASSKEY !== "0") { async function expectProtectedRouteToRedirect(page: Page): Promise { await page.goto("/profile"); - await expect(page).toHaveURL(/\/login(?:\?|$)/); + await expect(page).toHaveURL(loginUrl); } async function gotoLogin(page: Page): Promise { - const loginUrl = /\/login(?:\?|$)/; if (loginUrl.test(page.url())) { return; } @@ -142,10 +143,28 @@ async function skipPasskeyUpsellIfVisible(page: Page): Promise { } async function expectSignedIn(page: Page): Promise { - await page.waitForURL("**/profile", { timeout: 30_000 }); + if (expectsProtectedRouteRedirect) { + await expect(page).toHaveURL(profileUrl, { timeout: 30_000 }); + } else { + await expect(signedInLocator(page).first()).toBeVisible({ timeout: 30_000 }); + if (!profileUrl.test(page.url())) { + await gotoProfile(page); + } + } await expect(signedInLocator(page).first()).toBeVisible({ timeout: 30_000 }); } +async function gotoProfile(page: Page): Promise { + await page.goto("/profile").catch(async (error: unknown) => { + if (!isInterruptedByProfileNavigation(error) && !profileUrl.test(page.url())) { + throw error; + } + if (!profileUrl.test(page.url())) { + await page.waitForURL(profileUrl, { timeout: 5000 }); + } + }); +} + async function expectSessionCookie(page: Page): Promise { await expect .poll( @@ -187,8 +206,8 @@ async function logout(page: Page): Promise { await expect(logout.first()).toBeVisible({ timeout: 5000 }); await logout.first().click(); const loggedOutUrl = expectsProtectedRouteRedirect - ? /\/login(?:\?|$)/ - : /\/(?:login)?(?:\?|$)/; + ? loginUrl + : /\/(?:login)?(?:[/?#]|$)/; await expect(page).toHaveURL(loggedOutUrl); await expectSessionCleared(page); } @@ -296,8 +315,22 @@ function actionLocator(page: Page, actionName: string) { function isInterruptedByLoginNavigation(error: unknown): boolean { return ( error instanceof Error && - error.message.includes('interrupted by another navigation to "') && - error.message.includes("/login") + isExpectedNavigationRace(error.message, "/login") + ); +} + +function isInterruptedByProfileNavigation(error: unknown): boolean { + return ( + error instanceof Error && + isExpectedNavigationRace(error.message, "/profile") + ); +} + +function isExpectedNavigationRace(message: string, path: string): boolean { + return ( + (message.includes('interrupted by another navigation to "') || + message.includes("net::ERR_ABORTED at ")) && + message.includes(path) ); } From b58dcd0de6317570e9bfdd4d2091e4e734e5b1de Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Mon, 15 Jun 2026 13:29:54 -0700 Subject: [PATCH 3/3] ci: reduce redundant runtime gates --- .github/workflows/ci.yml | 9 +++++++++ AGENTS.md | 19 ++++++++++--------- docs/operations/env.example | 2 +- scripts/check.mjs | 8 ++++++-- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e35680b4c..169b2b7b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,6 +229,7 @@ jobs: retention-days: 7 go-smoke-test-embedded-postgres: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: depot-ubuntu-24.04-4 timeout-minutes: 15 needs: [goreleaser-snapshot] @@ -335,6 +336,7 @@ jobs: node scripts/check-changeset-required.mjs --base "origin/${{ github.base_ref }}" quickstart-smoke: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: depot-ubuntu-24.04-4 timeout-minutes: 20 needs: [goreleaser-snapshot] @@ -423,6 +425,7 @@ jobs: fi node-e2e: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: depot-ubuntu-24.04-4 timeout-minutes: 15 steps: @@ -792,11 +795,17 @@ jobs: steps: - name: Check CI gate results env: + IS_PULL_REQUEST: ${{ github.event_name == 'pull_request' }} NEEDS_JSON: ${{ toJson(needs) }} run: | node <<'NODE' const needs = JSON.parse(process.env.NEEDS_JSON); const allowedSkipped = new Set(["changeset-check"]); + if (process.env.IS_PULL_REQUEST === "true") { + allowedSkipped.add("go-smoke-test-embedded-postgres"); + allowedSkipped.add("quickstart-smoke"); + allowedSkipped.add("node-e2e"); + } const failed = Object.entries(needs).filter(([name, job]) => { if (job.result === "success") return false; if (job.result === "skipped" && allowedSkipped.has(name)) return false; diff --git a/AGENTS.md b/AGENTS.md index 076d54f1c..f358b4eaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,9 +131,9 @@ signaled and cleaned up directly. startup, then runs `go run .`. Direct `go run .` callers must sync the embed folders themselves or disable both embedded UI surfaces. -End-to-end tests are **opt-in locally** — they're not part of the -default `run-many -t lint,typecheck,build,test` invocation because they -boot real dev servers and need browsers installed: +Checked-in demo end-to-end tests are **opt-in locally** and main-only in CI. +They are not part of the default `run-many -t lint,typecheck,build,test` +invocation because they boot real dev servers and need browsers installed: ```sh corepack pnpm exec playwright install @@ -159,11 +159,12 @@ Use `corepack pnpm run journey` for deterministic CI-style proof of the fresh-app path. Use `corepack pnpm run cli -- ...` for manual browser or agent experiments against the same local package train. -In CI the dedicated `node-e2e` job (in `.github/workflows/ci.yml`) gates merges -on the checked-in demo integrations. The separate `consumer-journey-e2e` job is -the fresh-app quality gate: it consumes the current workflow's GoReleaser image -and npm package artifacts instead of public Zitadel packages. Browsers are -cached on the runner to reduce install cost. +In CI the `consumer-journey-e2e` matrix is the required PR runtime gate: it +consumes the current workflow's GoReleaser image and npm package artifacts +instead of public Zitadel packages. The checked-in demo integrations +(`node-e2e`), raw binary embedded-postgres smoke, and documented quick-start +compose smoke run on pushes to `main` as release-surface confidence checks. +Browsers are cached on the runner to reduce install cost. ## Testing Layers @@ -333,7 +334,7 @@ Standard commands are documented in root `AGENTS.md` → **Local Checks** and - **Fast local checks:** `corepack pnpm run check` - **Full local checks:** `corepack pnpm run check -- --full` -- **E2E:** `corepack pnpm nx run-many -t e2e -p @zitadel/demo-next-e2e,@zitadel/demo-nuxt-e2e` +- **Demo E2E (manual/main-only):** `corepack pnpm run check -- --only node:e2e` - **Consumer journey E2E:** `corepack pnpm run journey` ### Running demo apps manually diff --git a/docs/operations/env.example b/docs/operations/env.example index 0374d614f..491e954ac 100644 --- a/docs/operations/env.example +++ b/docs/operations/env.example @@ -1,5 +1,5 @@ # Copy to .env next to docker-compose.yaml (see docs/quick-start/index.md). -# On PR CI, quickstart-smoke overrides NEXTGEN_IMAGE with the Goreleaser snapshot tag. +# On main CI, quickstart-smoke overrides NEXTGEN_IMAGE with the Goreleaser snapshot tag. NEXTGEN_IMAGE=ghcr.io/zitadel/nextgen:latest NEXTGEN_PORT=8080 diff --git a/scripts/check.mjs b/scripts/check.mjs index ccb37b920..e1fdbcb87 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -16,8 +16,9 @@ const phases = new Map([ ["release", phaseRelease], ["journey", phaseJourney], ]); -const fullOrder = [...phases.keys()]; +const fullOrder = [...phases.keys()].filter((phase) => phase !== "node:e2e"); const fastOrder = ["openapi", "go", "node"]; +const explicitOnlyPhases = [...phases.keys()].filter((phase) => !fullOrder.includes(phase)); const options = parseArgs(forwardedArgs()); const selected = options.only ? [options.only] : options.full ? fullOrder : fastOrder; @@ -84,8 +85,11 @@ function usage(error) { Default is --fast. -Phases: +Full phases: ${fullOrder.join("\n ")} + +Explicit-only phases: + ${explicitOnlyPhases.join("\n ")} `); process.exit(error ? 1 : 0); }