diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c64861b0..e6178fda 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -8,6 +8,12 @@ on: schedule: - cron: "0 6 * * *" workflow_dispatch: + # Fires the init-cold-smoke job after Dev Release publishes the per-PR + # `dev/` tag, so the smoke can install via the same one-liner + # the dev-release bot posts on the PR. + workflow_run: + workflows: ["Dev Release"] + types: [completed] permissions: pull-requests: write # sticky PR comment posting @@ -466,3 +472,82 @@ jobs: -H "Accept: application/vnd.github+json" \ "$API_URL/repos/$REPO/issues" \ -d "$(jq -n --arg title "$TITLE" --arg body "$BODY" '{title: $title, body: $body}')" + + init-cold-smoke: + # Runs `dot init` inside a fresh ubuntu:22.04 container — no rustup, + # no IPFS, no foundry, no cdm pre-installed. The cell-based jobs + # above run on a GitHub Actions runner that already has most of + # those tools on PATH, so they can't catch regressions in the + # install / post-install path-config logic (e.g. paritytech/ + # playground-app#118 — newly-installed rustup unreachable from the + # same init process). This job is the cold-start defence: every + # dependency must be installed AND become reachable to subsequent + # init steps in the same process. + # + # Install path: the same `curl … install.sh | VERSION=… bash` + # one-liner the dev-release bot posts on every PR — i.e. exactly + # what an end user runs. install.sh downloads the SEA binary, + # adds it to PATH, then runs `dot init` as its final step (errors + # surface via exit code; see CLAUDE.md "Non-obvious invariants"). + # Going through the published binary catches Bun-compile-only + # regressions that `bun run src/index.ts` masks. + # + # Triggers: + # - workflow_run (Dev Release succeeded) → install dev/ + # for that PR; this is the per-PR cold-start gate. + # - schedule / workflow_dispatch on main → install latest + # stable release (no VERSION). + name: Init cold-start smoke test + runs-on: ${{ github.repository_owner == 'paritytech' && 'parity-default' || 'ubuntu-latest' }} + timeout-minutes: 30 + if: | + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') + steps: + - name: Run dot init via install.sh in fresh ubuntu container + env: + # Empty for schedule/dispatch → install.sh resolves the + # latest stable tag. Set to dev/ after a + # successful Dev Release so we test the PR's binary. + VERSION_OVERRIDE: ${{ github.event_name == 'workflow_run' && format('dev/{0}', github.event.workflow_run.head_branch) || '' }} + run: | + docker run --rm \ + -e CI=true \ + -e VERSION="$VERSION_OVERRIDE" \ + ubuntu:22.04 \ + bash -eux -c ' + # install.sh needs only curl + ca-certs to fetch the + # SEA binary. Anything beyond this is what `dot init` + # (called from install.sh) installs for itself. + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -qq -y --no-install-recommends \ + curl ca-certificates bash + + # ── The actual smoke test ────────────────────────────── + # End-user install path. install.sh exits non-zero if + # the embedded `dot init` fails, so the pipeline alone + # catches the #118-class "installed but unreachable" + # regression. We additionally grep the captured output + # for failed-dependency markers because some failure + # modes still let init exit 0 (e.g. an optional row + # rendered with the failure glyph). + curl -fsSL https://raw.githubusercontent.com/paritytech/playground-cli/main/install.sh \ + | bash 2>&1 | tee /tmp/init.log + + grep -q "setup complete" /tmp/init.log || { + echo "::error::dot init did not reach '\''setup complete'\''" + exit 1 + } + + # Reject failed-row markers rendered by the dot TUI. + # Fail glyph is U+2715 ✕ (see src/utils/ui/theme/ + # tokens.ts); we also accept U+2717 ✗ for terminal- + # font fallback. Word-anchored "failed" only — bare + # " error" would also match " errors" (e.g. apt's + # "0 errors") and apt warnings unrelated to the smoke. + if grep -E "✗ |✕ |\bFAILED\b|\bfailed dependency\b" /tmp/init.log; then + echo "::error::dot init reported a failed dependency" + exit 1 + fi + ' diff --git a/e2e/cli/build.test.ts b/e2e/cli/build.test.ts index 6076168e..391382c1 100644 --- a/e2e/cli/build.test.ts +++ b/e2e/cli/build.test.ts @@ -1,47 +1,79 @@ -import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { existsSync, rmSync } from "node:fs"; -import { resolve } from "node:path"; +import { describe, test, expect, afterEach } from "vitest"; +import { cpSync, existsSync, mkdtempSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; import { dot } from "./helpers/dot.js"; import { fixturePath } from "./fixtures/templates.js"; -describe("dot build", () => { - const frontendOnly = fixturePath("frontend-only"); - const distDir = resolve(frontendOnly, "dist"); +/** + * Copy a fixture into a fresh temp dir so the build output lives there, + * not inside the source-controlled fixture. This keeps build.test.ts + * order-independent: previously we deleted `frontend-only/dist` between + * tests, which left the dir absent for any later test that ran with + * `--no-build` (deploy, diagnostic). With a copy, the original fixture + * is never touched. + */ +function stageFixture(name: string): string { + const dir = mkdtempSync(join(tmpdir(), `dot-e2e-build-${name}-`)); + cpSync(fixturePath(name), dir, { recursive: true }); + return dir; +} - beforeEach(() => { - if (existsSync(distDir)) { - rmSync(distDir, { recursive: true }); - } - }); +describe("dot build", () => { + const stagedDirs: string[] = []; afterEach(() => { - if (existsSync(distDir)) { - rmSync(distDir, { recursive: true }); + for (const d of stagedDirs) { + try { + rmSync(d, { recursive: true, force: true }); + } catch { /* best-effort */ } } + stagedDirs.length = 0; }); + function stage(name: string): string { + const d = stageFixture(name); + stagedDirs.push(d); + return d; + } + test("builds a frontend-only project", async () => { - const result = await dot(["build", "--dir", frontendOnly]); - expect(result.exitCode).toBe(0); + const project = stage("frontend-only"); + // The fixture ships a pre-built dist for use by deploy tests. + // Remove it from the staged copy so we can assert that *this* + // build invocation produced the artifacts. + const distDir = resolve(project, "dist"); + if (existsSync(distDir)) rmSync(distDir, { recursive: true }); + + const result = await dot(["build", "--dir", project]); + expect( + result.exitCode, + `build failed: ${result.stdout}\n${result.stderr}`, + ).toBe(0); expect(result.stdout).toContain("Build succeeded"); expect(existsSync(resolve(distDir, "index.html"))).toBe(true); }); test("exits non-zero on build failure with error output", async () => { - const broken = fixturePath("broken-contract"); - const result = await dot(["build", "--dir", broken]); + const project = stage("broken-contract"); + const result = await dot(["build", "--dir", project]); expect(result.exitCode).not.toBe(0); - // The broken-contract fixture's build script writes to stderr + // The fixture's package.json runs: + // "build": "echo 'Compile error: unexpected token' >&2 && exit 1" + // Match the exact text — proves the runner invoked the user's script + // and surfaced its stderr, not just any pipeline-level error. const output = result.stdout + result.stderr; - expect(output).toMatch(/error|fail/i); + expect(output).toContain("Compile error: unexpected token"); }); test("exits non-zero when no build strategy can be detected", async () => { - const contractsOnly = fixturePath("contracts-only"); - const result = await dot(["build", "--dir", contractsOnly]); + const project = stage("contracts-only"); + const result = await dot(["build", "--dir", project]); expect(result.exitCode).not.toBe(0); - // contracts-only has no package.json — no build strategy + // Exact wording from src/utils/build/detect.ts: + // `No build strategy detected. Add a "build" script to package.json, + // or install vite/next/typescript.` const output = result.stdout + result.stderr; - expect(output).toMatch(/no build|detect|strategy|package\.json/i); + expect(output).toContain("No build strategy detected"); }); }); diff --git a/e2e/cli/chaos.test.ts b/e2e/cli/chaos.test.ts index 3acfa18d..629b4e3e 100644 --- a/e2e/cli/chaos.test.ts +++ b/e2e/cli/chaos.test.ts @@ -42,6 +42,7 @@ function chaosDeployArgs(domain: string, buildDir: string, dir: string): string[ "--buildDir", buildDir, "--playground", + "--private", "--suri", SIGNER.suri, "--dir", diff --git a/e2e/cli/deploy.test.ts b/e2e/cli/deploy.test.ts index dde602bb..a72f12b9 100644 --- a/e2e/cli/deploy.test.ts +++ b/e2e/cli/deploy.test.ts @@ -18,6 +18,16 @@ import { resolve } from "node:path"; import { dot } from "./helpers/dot.js"; import { SIGNER, BOB, E2E_DOMAINS } from "./fixtures/accounts.js"; import { fixturePath } from "./fixtures/templates.js"; +import { getApp } from "./fixtures/registry.js"; + +/** Pull the metadata CID out of the headless deploy summary. The CLI + * prints ` Metadata CID bafy...` once per successful deploy + * (see src/commands/deploy/index.ts printFinalResult). Returns null + * if absent — that itself is a meaningful signal. */ +function extractMetadataCid(stdout: string): string | null { + const m = stdout.match(/Metadata CID\s+(\S+)/); + return m ? m[1] : null; +} const frontendOnly = fixturePath("frontend-only"); const foundry = fixturePath("foundry"); @@ -58,6 +68,7 @@ function runContractDeployTest(cfg: ContractDeployTestConfig): void { "--contracts", "--no-contract-build", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", cfg.fixture, ], { timeout: 400_000 }); @@ -72,6 +83,18 @@ function runContractDeployTest(cfg: ContractDeployTestConfig): void { }); } +/** + * Assertion notes for the preflight tests below: + * - "Checking availability" is printed by `src/commands/deploy/index.ts` ONLY + * after preflight (signer + mapping + balance) has succeeded. Asserting on + * it is a real checkpoint — a deploy that crashes earlier won't print it. + * - Avoid loose regexes like `/deploy/i`: the literal word "deploy" appears + * in the command banner, error-help text, and stack traces, so it matches + * even when nothing meaningful happened. + * - For tests that expect failure, assert `exitCode !== 0` so an early crash + * that prints something tangentially matching the regex can't slip through. + */ + describe("dot deploy — preflight and validation", () => { test("reports mainnet not yet supported", async () => { const result = await dot([ @@ -80,13 +103,21 @@ describe("dot deploy — preflight and validation", () => { "--domain", E2E_DOMAINS.preflight, "--buildDir", absBuildDir(frontendOnly), "--playground", + "--private", "--env", "mainnet", "--suri", SIGNER.suri, "--dir", frontendOnly, ], { timeout: 400_000 }); const output = result.stdout + result.stderr; - expect(output).toMatch(/mainnet/i); - expect(output).toMatch(/not.*supported/i); + expect( + result.exitCode, + `expected non-zero exit for --env mainnet, got 0\n${output}`, + ).not.toBe(0); + // Exact wording from src/commands/deploy/index.ts: "`--env mainnet` is + // not yet supported. Use `--env testnet` (default) while mainnet launch + // is pending." + expect(output).toContain("not yet supported"); + expect(output).toContain("--env testnet"); }); test("detects foundry contracts type in project", async () => { @@ -98,14 +129,18 @@ describe("dot deploy — preflight and validation", () => { "--no-build", "--contracts", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", foundry, ]); const output = result.stdout + result.stderr; // foundry.toml present → should not complain about missing contract project expect(output).not.toContain("no foundry/hardhat/cdm project was detected"); - // Should proceed to at least the availability check - expect(output).toMatch(/checking availability|deploy/i); + // Real checkpoint: only printed after preflight succeeds. + expect( + output, + `expected to reach availability check\n${output}`, + ).toContain("Checking availability"); }); test("detects hardhat contracts type in project", async () => { @@ -117,12 +152,16 @@ describe("dot deploy — preflight and validation", () => { "--no-build", "--contracts", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", hardhat, ]); const output = result.stdout + result.stderr; expect(output).not.toContain("no foundry/hardhat/cdm project was detected"); - expect(output).toMatch(/checking availability|deploy/i); + expect( + output, + `expected to reach availability check\n${output}`, + ).toContain("Checking availability"); }); test("detects CDM/Rust contracts type in project", async () => { @@ -134,12 +173,16 @@ describe("dot deploy — preflight and validation", () => { "--no-build", "--contracts", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", rustCdm, ]); const output = result.stdout + result.stderr; expect(output).not.toContain("no foundry/hardhat/cdm project was detected"); - expect(output).toMatch(/checking availability|deploy/i); + expect( + output, + `expected to reach availability check\n${output}`, + ).toContain("Checking availability"); }); test("detects multiple contracts in multi-contract project", async () => { @@ -151,12 +194,16 @@ describe("dot deploy — preflight and validation", () => { "--no-build", "--contracts", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", multiContract, ]); const output = result.stdout + result.stderr; expect(output).not.toContain("no foundry/hardhat/cdm project was detected"); - expect(output).toMatch(/checking availability|deploy/i); + expect( + output, + `expected to reach availability check\n${output}`, + ).toContain("Checking availability"); }); test("--contracts reports error when no contract project detected", async () => { @@ -168,10 +215,15 @@ describe("dot deploy — preflight and validation", () => { "--no-build", "--contracts", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", frontendOnly, ], { timeout: 400_000 }); const output = result.stdout + result.stderr; + expect( + result.exitCode, + `expected non-zero exit when --contracts has no project\n${output}`, + ).not.toBe(0); expect(output).toContain("no foundry/hardhat/cdm project was detected"); }); @@ -183,12 +235,41 @@ describe("dot deploy — preflight and validation", () => { "--domain", domain, "--buildDir", absBuildDir(frontendOnly), "--playground", + "--private", "--suri", SIGNER.suri, "--dir", frontendOnly, ], { timeout: 400_000 }); const output = result.stdout + result.stderr; - expect(output).toContain("Checking availability"); - expect(output).toContain(domain); + // Availability banner names the domain; this is the strongest signal we + // have that the availability check actually executed against this run's + // domain (rather than echoing the arg in a usage/error string). + const availIdx = output.indexOf(`Checking availability of ${domain}.dot`); + expect( + availIdx, + `availability banner not found:\n${output}`, + ).toBeGreaterThan(-1); + // Verify the *ordering* claim in the test name: availability must + // precede any build-runner output. The dot CLI's build banner is + // `\n> ${config.description}\n` from src/commands/build.ts:15, and + // `config.description` is always one of `pnpm/npm/yarn/bun/npx ` + // (see src/utils/build/detect.ts). Anchoring on that exact prefix + // avoids matching unrelated `> ` lines that build-tool stdout itself + // can produce — pnpm prints `> @scope/pkg@1 build /path` for every + // run-script, vite logs `> built in 234ms`, etc. + const buildIdx = output.search(/\n> (?:pnpm|npm|yarn|bun|npx) /); + const storageIdx = output.indexOf("▸ storage-and-dotns"); + if (buildIdx > -1) { + expect( + availIdx, + `build header appeared before availability check:\n${output}`, + ).toBeLessThan(buildIdx); + } + if (storageIdx > -1) { + expect( + availIdx, + `storage phase started before availability check:\n${output}`, + ).toBeLessThan(storageIdx); + } }); }); @@ -201,6 +282,7 @@ describe("dot deploy --playground — full pipeline (requires Paseo + IPFS)", () "--domain", domain, "--buildDir", absBuildDir(frontendOnly), "--playground", + "--private", "--suri", SIGNER.suri, "--dir", frontendOnly, ], { timeout: 400_000 }); @@ -212,6 +294,19 @@ describe("dot deploy --playground — full pipeline (requires Paseo + IPFS)", () expect(result.stdout).toContain("Deploy complete"); expect(result.stdout).toContain("URL"); expect(result.stdout).toContain(domain); + + // Don't trust the CLI's own success message — query the registry + // independently to prove the entry was actually written. A regression + // where deploy prints "Deploy complete" but never sends the registry + // extrinsic would otherwise pass. + const cliCid = extractMetadataCid(result.stdout); + expect(cliCid, "CLI did not print Metadata CID").not.toBeNull(); + const entry = await getApp(`${domain}.dot`); + expect(entry, `registry has no entry for ${domain}.dot`).not.toBeNull(); + // Belt-and-braces: the on-chain CID should match what the CLI claims + // it published. A divergence here means the CLI is reporting one CID + // to the user while writing a different one to the chain. + expect(entry!.metadataUri).toContain(cliCid!); }); test("re-deploy same domain succeeds for same owner", { timeout: 900_000 }, async () => { @@ -222,11 +317,14 @@ describe("dot deploy --playground — full pipeline (requires Paseo + IPFS)", () "--domain", domain, "--buildDir", absBuildDir(frontendOnly), "--playground", + "--private", "--suri", SIGNER.suri, "--dir", frontendOnly, ], { timeout: 400_000 }); expect(first.exitCode, `first deploy failed: ${first.stdout}\n${first.stderr}`).toBe(0); expect(first.stdout).toContain("Deploy complete"); + const firstCid = extractMetadataCid(first.stdout); + expect(firstCid, "first deploy did not print Metadata CID").not.toBeNull(); const second = await dot([ "deploy", @@ -235,6 +333,7 @@ describe("dot deploy --playground — full pipeline (requires Paseo + IPFS)", () "--buildDir", absBuildDir(frontendOnly), "--no-build", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", frontendOnly, ], { timeout: 400_000 }); @@ -243,6 +342,21 @@ describe("dot deploy --playground — full pipeline (requires Paseo + IPFS)", () `re-deploy failed: ${second.stdout}\n${second.stderr}`, ).toBe(0); expect(second.stdout).toContain("Deploy complete"); + const secondCid = extractMetadataCid(second.stdout); + expect(secondCid, "re-deploy did not print Metadata CID").not.toBeNull(); + // NOTE: do NOT assert `secondCid !== firstCid`. The metadata JSON only + // includes `{repository, readme}` (see buildMetadata in src/utils/ + // deploy/playground.ts) — neither changes on a same-fixture redeploy, + // so the CID is content-addressed to the same value both times. That's + // correct behaviour: a same-CID re-publish means the registry already + // has what the user wants. + // + // Independent registry check: the on-chain entry must contain the CID + // the CLI claims it published. This catches regressions where the CLI + // prints "Deploy complete" but never sent the registry extrinsic. + const entry = await getApp(`${domain}.dot`); + expect(entry).not.toBeNull(); + expect(entry!.metadataUri).toContain(secondCid!); }); test("domain taken by another account shows unavailable", { timeout: 900_000 }, async () => { @@ -253,6 +367,7 @@ describe("dot deploy --playground — full pipeline (requires Paseo + IPFS)", () "--domain", domain, "--buildDir", absBuildDir(frontendOnly), "--playground", + "--private", "--suri", SIGNER.suri, "--dir", frontendOnly, ], { timeout: 400_000 }); @@ -267,6 +382,7 @@ describe("dot deploy --playground — full pipeline (requires Paseo + IPFS)", () "--domain", domain, "--buildDir", absBuildDir(frontendOnly), "--playground", + "--private", "--suri", BOB.suri, "--dir", frontendOnly, ], { timeout: 400_000 }); @@ -299,6 +415,7 @@ describe("dot deploy — rejects --no-contract-build with no artefacts", () => { "--contracts", "--no-contract-build", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", constructorArgs, ]); @@ -330,6 +447,7 @@ describe.skip("dot deploy — CDM (requires Paseo + IPFS)", () => { "--contracts", "--no-contract-build", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", rustCdm, ], { timeout: 400_000 }); diff --git a/e2e/cli/diagnostic.test.ts b/e2e/cli/diagnostic.test.ts index 76851e5e..c9f05e64 100644 --- a/e2e/cli/diagnostic.test.ts +++ b/e2e/cli/diagnostic.test.ts @@ -6,39 +6,82 @@ */ import { describe, test, expect } from "vitest"; +import { resolve } from "node:path"; import { dot } from "./helpers/dot.js"; -import { ALICE } from "./fixtures/accounts.js"; +import { SIGNER, E2E_DOMAINS } from "./fixtures/accounts.js"; import { fixturePath } from "./fixtures/templates.js"; const frontendOnly = fixturePath("frontend-only"); describe("diagnostic mode", () => { - test("DOT_DEPLOY_VERBOSE=1 does not break deploy preflight", async () => { - // Run a deploy that will reach preflight with verbose enabled. - // We don't need it to succeed — just verify verbose doesn't crash anything. + test( + "DOT_DEPLOY_VERBOSE=1 produces timestamped log lines during storage phase", + { timeout: 300_000 }, + async () => { + // Need a deploy that actually reaches the storage phase — that's + // where bulletin-deploy logs and verbose-mode wraps its output + // with "[+s] " timestamps (see src/utils/deploy/storage.ts). + // Re-deploying a domain SIGNER already owns is the cheapest way + // to get there. + const result = await dot([ + "deploy", + "--signer", "dev", + "--domain", E2E_DOMAINS.preflight, + "--buildDir", resolve(frontendOnly, "dist"), + "--no-build", + "--playground", + "--private", + "--suri", SIGNER.suri, + "--dir", frontendOnly, + ], { + env: { DOT_DEPLOY_VERBOSE: "1" }, + timeout: 280_000, + }); + const output = result.stdout + result.stderr; + // Real preflight checkpoint — only printed after signer/mapping + // passes. + expect( + output, + `expected to reach availability check with verbose on\n${output}`, + ).toContain("Checking availability"); + // Verbose-only marker. Format: "[+12.3s] ". This prefix + // appears nowhere else, so matching it is the only way to prove + // DOT_DEPLOY_VERBOSE wasn't silently ignored. + expect( + output, + `expected verbose-only "[+Ns] ..." marker in output\n${output}`, + ).toMatch(/\[\+\d+\.\d+s\]/); + }, + ); + + test("DOT_MEMORY_TRACE=1 produces RSS sample lines during a real command", async () => { + // `--help` exits before the memory watchdog has a chance to sample. + // Run a deploy preflight instead — the watchdog samples once per + // second and writes RSS/heap/external lines to stderr when the env + // var is set (see src/utils/process-guard.ts startMemoryWatchdog). const result = await dot([ "deploy", "--signer", "dev", - "--domain", "diag-verbose-test", - "--buildDir", "dist", + "--domain", E2E_DOMAINS.preflight, + "--buildDir", resolve(frontendOnly, "dist"), + "--no-build", "--playground", - "--suri", ALICE.suri, + "--private", + "--suri", SIGNER.suri, "--dir", frontendOnly, ], { - env: { DOT_DEPLOY_VERBOSE: "1" }, - timeout: 30_000, - }); - const output = result.stdout + result.stderr; - // Should reach the availability check even with verbose on - expect(output).toMatch(/checking availability|deploy|mainnet/i); - }); - - test("DOT_MEMORY_TRACE=1 does not prevent normal operation", async () => { - const result = await dot(["--help"], { env: { DOT_MEMORY_TRACE: "1" }, - timeout: 15_000, + timeout: 120_000, }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("deploy"); + const output = result.stdout + result.stderr; + // Verbose-only prefix from src/utils/process-guard.ts watchdog worker: + // "[mem +s] rss= heap=/ external=... peak=..." + // That bracketed prefix is unique to this code path, so matching it + // proves DOT_MEMORY_TRACE actually engaged the sampler — not just + // that some other code wrote the word "rss" somewhere. + expect( + output, + `expected memory-trace markers in output\n${output}`, + ).toMatch(/\[mem \+\d+\.\d+s\]/); }); }); diff --git a/e2e/cli/fixtures/registry.ts b/e2e/cli/fixtures/registry.ts index 5248ed26..b728ca20 100644 --- a/e2e/cli/fixtures/registry.ts +++ b/e2e/cli/fixtures/registry.ts @@ -13,6 +13,9 @@ import { suppressReviveTraceNoise, withRequiredLiveContractAddresses, } from "../../../src/utils/contractManifest.js"; +import { resolveSigner } from "../../../src/utils/signer.js"; +import { publishToPlayground } from "../../../src/utils/deploy/playground.js"; +import { SIGNER } from "./accounts.js"; import cdmJson from "../../../cdm.json"; @@ -105,8 +108,17 @@ export async function waitForApp(domain: string, timeoutMs = 30_000): Promise { const domain = process.env.TEST_TEMPLATE_DOMAIN; @@ -115,11 +127,35 @@ export async function ensureTemplateRegistered(): Promise { return; } - const entry = await getApp(domain); - if (!entry) { + const existing = await getApp(domain); + if (existing) { + console.log(`[e2e setup] Template "${domain}" already registered`); + return; + } + + const repositoryUrl = process.env.TEST_TEMPLATE_REPO; + if (!repositoryUrl) { throw new Error( - `Test template app "${domain}" not registered in the playground registry — run setup script first.`, + `Test template "${domain}" is not registered and TEST_TEMPLATE_REPO is unset — ` + + `cannot bootstrap. Set TEST_TEMPLATE_REPO to the upstream source repository.`, ); } - console.log(`[e2e setup] Template "${domain}" verified in registry`); + + console.log( + `[e2e setup] Template "${domain}" missing — registering against the live registry contract…`, + ); + // Same SIGNER the rest of the suite uses — it's already funded by + // fundDeployerIfLow() earlier in globalSetup, so no extra balance check + // here. Mirrors tools/register-e2e-fixtures.ts. + const signer = await resolveSigner({ suri: SIGNER.suri }); + try { + const result = await publishToPlayground({ + domain, + publishSigner: signer, + repositoryUrl, + }); + console.log(`[e2e setup] Template "${domain}" registered (cid ${result.metadataCid})`); + } finally { + signer.destroy(); + } } diff --git a/e2e/cli/init.test.ts b/e2e/cli/init.test.ts index 57c71940..ec184d8a 100644 --- a/e2e/cli/init.test.ts +++ b/e2e/cli/init.test.ts @@ -6,10 +6,21 @@ * - Behavior when no session exists (prompts for QR, times out) * - Corrupted session file handling * - Dev signer (--suri) bypasses session requirement + * + * KNOWN GAP — toolchain install paths (rustup, IPFS, foundry, cdm) are NOT + * exercised here because CI runners (parity-default, GitHub-hosted) already + * have those tools on PATH, often via wrapper scripts in /usr/bin that + * delegate into $HOME/.cargo. PATH-stripping a wrapper without breaking + * sibling binaries in the same dir isn't possible. The cold-start smoke + * job (.github/workflows/e2e.yml :: init-cold-smoke) installs the dev/ + * SEA binary via install.sh inside a fresh ubuntu:22.04 container with no + * toolchain pre-installed and is the authoritative test for detection + + * install-then-use. It runs after Dev Release on each PR, plus daily on + * main and on workflow_dispatch (against the latest stable release). */ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { dot } from "./helpers/dot.js"; @@ -36,28 +47,67 @@ describe("dot init — session detection", () => { } catch { /* best-effort */ } }); - test("init with no session times out waiting for QR scan", async () => { - const result = await dot(["init", "-y"], { + test("init with no session prompts for QR scan", async () => { + // IMPORTANT: do NOT pass `-y` here. With `-y`, init skips the + // connect()/login block entirely — there's no session probe and no + // QR. The previous version of this test used `-y` and only asserted + // `exitCode !== 0`, which simply verified that toolchain installation + // in a fresh tempHome takes longer than 15s — nothing about sessions. + const result = await dot(["init"], { home: tempHome, - timeout: 15_000, + timeout: 25_000, }); - // With no session files and no phone to scan, the CLI should either: - // - time out waiting for the statement store QR - // - exit non-zero because -y can't complete without a session - // Either way it should not succeed silently. - expect(result.exitCode).not.toBe(0); + const output = result.stdout + result.stderr; + expect( + result.exitCode, + `expected non-zero exit while waiting for QR\n${output}`, + ).not.toBe(0); + // We expect the QR prompt. If init fell into the "Login skipped" + // branch instead, the login service was unreachable from this runner + // — the test cannot validate the QR rendering and we should fail + // loudly rather than silently accept a degraded path. (Previously + // the assertion was a `Scan|Login skipped` regex, which let that + // degradation pass invisibly.) + if (output.includes("Login skipped")) { + throw new Error( + "Login service unreachable from runner — cannot validate QR " + + "flow. Either fix the runner's network/auth-service access or " + + "add an offline session-injection fixture (paritytech/" + + "playground-cli#50).\n\n" + output, + ); + } + expect(output).toContain("Scan with the Polkadot mobile app to log in"); }); - test("init with corrupted session file does not succeed", async () => { + test("init with corrupted session file does not silently succeed", async () => { const sessionFile = join(tempHome, ".polkadot-apps", "dot-cli_SsoSessions.json"); - writeFileSync(sessionFile, "{{{{not valid json!!"); + const corrupt = "{{{{not valid json!!"; + writeFileSync(sessionFile, corrupt); - const result = await dot(["init", "-y"], { + const result = await dot(["init"], { home: tempHome, - timeout: 15_000, + timeout: 25_000, }); - // Corrupted session should cause a parse error or fall through to QR timeout + const output = result.stdout + result.stderr; expect(result.exitCode).not.toBe(0); + // A corrupted session file must NOT lead to an "existing session" + // branch. We expect the QR prompt; "Login skipped" again indicates + // service unreachable and is treated as an inconclusive run, not a + // pass. + if (output.includes("Login skipped")) { + throw new Error( + "Login service unreachable from runner — cannot validate " + + "corrupt-session rejection. See no-session test for context.\n\n" + + output, + ); + } + expect(output).toContain("Scan with the Polkadot mobile app to log in"); + + // Defence-in-depth: init must NOT have silently overwritten the + // corrupt file with a fresh empty session. A regression that + // "fixes" the parse failure by deleting the file would otherwise + // pass — and silently erase whatever the user had on disk. + expect(readFileSync(sessionFile, "utf8")).toBe(corrupt); }); }); @@ -75,3 +125,4 @@ describe("dot init — dev signer bypass", () => { } }); }); + diff --git a/e2e/cli/install.test.ts b/e2e/cli/install.test.ts index 92e2b1a9..57b3f995 100644 --- a/e2e/cli/install.test.ts +++ b/e2e/cli/install.test.ts @@ -20,11 +20,17 @@ describe("dot install", () => { expect(output).toContain("update"); }); - test("dot update succeeds and returns updated version", async () => { + test("dot update reports a meaningful outcome", async () => { const result = await dot(["update"]); - // update may exit 0 (updated or already up-to-date) expect(result.exitCode).toBe(0); - // Verify dot --version still works after update + // Without this, a regression where `dot update` silently no-ops is + // invisible. Match either exact wording from src/commands/update.ts: + // "already on latest (vX.Y.Z)" — when current === latest tag + // "Updated dot to vX.Y.Z" — when an update happened + // Both branches print "Checking for updates..." first, so anchor on + // the outcome line. + expect(result.stdout).toMatch(/already on latest \(v|Updated dot to v/); + // Verify the binary still works after the update reported success. const version = await dot(["--version"]); expect(version.exitCode).toBe(0); expect(version.stdout).toMatch(/\d+\.\d+\.\d+/); diff --git a/e2e/cli/mod.test.ts b/e2e/cli/mod.test.ts index 5cbffb5d..4f4944d5 100644 --- a/e2e/cli/mod.test.ts +++ b/e2e/cli/mod.test.ts @@ -11,7 +11,7 @@ */ import { describe, test, expect, afterEach } from "vitest"; -import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { dot } from "./helpers/dot.js"; @@ -37,16 +37,20 @@ afterEach(() => { describe("dot mod — clone", () => { test.skipIf(!TEST_DOMAIN)( - "clones the registered template into a fresh directory", - { timeout: 180_000 }, + "full clone flow: fetches source, runs setup.sh, writes dot.json", + { timeout: 240_000 }, async () => { const cwd = makeTempDir("dot-e2e-mod-cwd-"); const result = await dot(["mod", TEST_DOMAIN, "--suri", ALICE.suri], { cwd, - timeout: 180_000, + timeout: 240_000, }); - expect(result.exitCode).toBe(0); + expect( + result.exitCode, + `mod failed:\n${result.stdout}\n${result.stderr}`, + ).toBe(0); + // defaultRepoName slugifies the domain and appends a 6-hex suffix. const slug = TEST_DOMAIN.replace(/\.dot$/, "") .toLowerCase() @@ -55,7 +59,56 @@ describe("dot mod — clone", () => { (name) => name.startsWith(`${slug}-`) && /-[0-9a-f]{6}$/.test(name), ); expect(created).toHaveLength(1); - expect(existsSync(join(cwd, created[0]!, "package.json"))).toBe(true); + const projectDir = join(cwd, created[0]!); + + // ── Step 2 (download source) — content from GitHub ───────────── + // Asserting on multiple specific files proves the tarball was + // actually fetched and extracted, not invented from thin air. + // All four files are committed to paritytech/Rock-Paper-Scissors's + // default branch — if any go missing, the upstream fixture has + // been restructured and this test needs updating to match. + expect(existsSync(join(projectDir, "package.json"))).toBe(true); + expect(existsSync(join(projectDir, "README.md"))).toBe(true); + expect(existsSync(join(projectDir, "setup.sh"))).toBe(true); + expect(existsSync(join(projectDir, "vite.config.ts"))).toBe(true); + + // ── Step 2 side effect: writeDotJson() ───────────────────────── + const dotJsonPath = join(projectDir, "dot.json"); + expect(existsSync(dotJsonPath)).toBe(true); + const dotJson = JSON.parse(readFileSync(dotJsonPath, "utf-8")) as { + domain?: string; + name?: string; + }; + // `domain` is set to `targetDir` in writeDotJson() — and + // `targetDir` flows from defaultRepoName(), which returns a + // RELATIVE slug-suffix path (e.g. `dot-cli-mod-fixture-a3b2c1`), + // not an absolute one. So dotJson.domain should equal the leaf + // directory name, NOT projectDir. (Field name is unusual but + // documented in src/commands/mod/SetupScreen.tsx.) + expect(dotJson.domain).toBe(created[0]); + expect(dotJson.name).toBeDefined(); + + // ── Step 2 side effect: ignoreModLogs() ──────────────────────── + const gitignore = readFileSync( + join(projectDir, ".gitignore"), + "utf-8", + ); + expect(gitignore).toContain(".dot-mod-setup.log"); + expect(gitignore).toContain(".dot-mod-source.log"); + + // ── Step 3 (run setup.sh) ────────────────────────────────────── + // The script's first line of substantive output is + // `echo "[setup] Rock Paper Scissors tutorial"`. If the log file + // exists with that prefix, setup.sh actually ran. (We can't + // assert exit 0 of the script here — exit-code propagation is + // already covered by the outer `result.exitCode` check above.) + const setupLog = join(projectDir, ".dot-mod-setup.log"); + expect( + existsSync(setupLog), + `setup.sh log not found — step 3 did not run.\n${result.stdout}\n${result.stderr}`, + ).toBe(true); + const setupLogContent = readFileSync(setupLog, "utf-8"); + expect(setupLogContent).toContain("[setup]"); }, ); @@ -65,19 +118,34 @@ describe("dot mod — clone", () => { const result = await dot(["mod", "some-app.dot"], { home: tempHome, cwd }); expect(result.exitCode).not.toBe(0); const output = result.stdout + result.stderr; - expect(output).toMatch(/signer|init|log.?in/i); + // Exact wording from src/utils/signer.ts SignerNotAvailableError: + // `No signer available. Run "dot init" to log in, or pass --suri //Alice for dev.` + // The previous regex /signer|init|log.?in/i matched any of those words + // anywhere — including help text — so it passed even on early crashes + // that never reached the signer-resolution path. + expect(output).toContain("No signer available"); + expect(output).toContain("dot init"); }); }); describe("dot mod — registry miss", () => { - test("reports a registry-miss for an unknown domain", async () => { + test("reports a registry-miss for an unknown domain", { timeout: 120_000 }, async () => { const cwd = makeTempDir("dot-e2e-mod-unknown-"); + const domain = "nonexistent-domain-xyz-12345.dot"; const result = await dot( - ["mod", "nonexistent-domain-xyz-12345.dot", "--suri", ALICE.suri], - { cwd }, + ["mod", domain, "--suri", ALICE.suri], + { cwd, timeout: 120_000 }, ); const output = result.stdout + result.stderr; - expect(result.exitCode).not.toBe(0); - expect(output).toMatch(/not found/i); + expect( + result.exitCode, + `expected non-zero exit for unknown domain\n${output}`, + ).not.toBe(0); + // Exact wording from src/commands/mod/SetupScreen.tsx: + // throw new Error(`App "${domain}" not found in registry`); + // Matching both fragments rules out an unrelated "not found" landing + // in output (e.g., a transient 404 from an IPFS gateway probe). + expect(output).toContain(domain); + expect(output).toContain("not found in registry"); }); }); diff --git a/e2e/cli/session.test.ts b/e2e/cli/session.test.ts index 26c0e03a..1a7ab1da 100644 --- a/e2e/cli/session.test.ts +++ b/e2e/cli/session.test.ts @@ -45,29 +45,47 @@ describe("session management", () => { ["deploy", "--signer", "phone", "--domain", "test", "--playground", "--buildDir", "dist"], { home: tempHome, timeout: 30_000 }, ); - // Must fail with a signer/session error + // Must fail with a signer-resolution error. Match the exact + // SignerNotAvailableError text from src/utils/signer.ts so a generic + // "session" mention in an unrelated stack trace can't satisfy this. expect(result.exitCode).not.toBe(0); - const output = (result.stdout + result.stderr).toLowerCase(); - expect(output).toMatch(/no signer|no.*session|signer.*not|run.*dot init/); + const output = result.stdout + result.stderr; + expect(output).toContain("No signer available"); }); test("build does not create or modify session files", async () => { const frontendOnly = fixturePath("frontend-only"); const sessionDir = join(tempHome, ".polkadot-apps"); - await dot(["build", "--dir", frontendOnly], { home: tempHome }); + // Verify each build actually succeeds — otherwise "no session files + // were touched" is a tautology (a crashed build can't write any file). + const first = await dot(["build", "--dir", frontendOnly], { home: tempHome }); + expect( + first.exitCode, + `first build failed: ${first.stdout}\n${first.stderr}`, + ).toBe(0); const afterFirst = getSessionFiles(sessionDir); - await dot(["build", "--dir", frontendOnly], { home: tempHome }); + const second = await dot(["build", "--dir", frontendOnly], { home: tempHome }); + expect( + second.exitCode, + `second build failed: ${second.stdout}\n${second.stderr}`, + ).toBe(0); const afterSecond = getSessionFiles(sessionDir); // build doesn't need auth — no session files should be created - expect(afterFirst).toEqual(afterSecond); + expect(afterFirst).toEqual([]); + expect(afterSecond).toEqual([]); }); test("logout with no session reports no account signed in", async () => { const result = await dot(["logout"], { home: tempHome, timeout: 30_000 }); - const output = (result.stdout + result.stderr).toLowerCase(); - expect(output).toMatch(/no.*sign|not.*log|no.*session|no.*account/); + expect( + result.exitCode, + `logout crashed: ${result.stdout}\n${result.stderr}`, + ).toBe(0); + // Exact wording from src/commands/logout/index.ts: + // console.log(" No account is signed in.\n"); + expect(result.stdout).toContain("No account is signed in"); }); }); diff --git a/e2e/cli/setup/global.ts b/e2e/cli/setup/global.ts index a02d816b..d1eafc44 100644 --- a/e2e/cli/setup/global.ts +++ b/e2e/cli/setup/global.ts @@ -16,14 +16,42 @@ export async function setup() { console.log(`[e2e setup] SIGNER (${SIGNER.name}): ${SIGNER.address} (h160 ${SIGNER.h160})`); console.log(`[e2e setup] BOB: ${BOB.address}`); - // These require chain connectivity — they'll log warnings if the chain - // is unreachable rather than failing the entire suite. This lets the - // no-chain tests (install, build) still run in offline environments. + // Funding SIGNER is mandatory — every deploy/mod test signs with it, + // and if it runs out of PAS the failures surface as cryptic + // "Invalid Payment" extrinsic errors deep inside individual tests. + // Fail the whole suite up front instead, with a clear message. + // + // To run only the offline-eligible tests (install, build, --help-style + // init checks) without chain connectivity, set E2E_ALLOW_OFFLINE_SETUP=1 + // — this is for local development on a flaky network, never CI. + const allowOffline = process.env.E2E_ALLOW_OFFLINE_SETUP === "1"; try { await fundDeployerIfLow(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (allowOffline) { + console.warn( + `[e2e setup] Funder unreachable (E2E_ALLOW_OFFLINE_SETUP=1 set, ` + + `continuing): ${msg}`, + ); + } else { + throw new Error( + `[e2e setup] Failed to fund SIGNER ${SIGNER.address} from the ` + + `production funder chain. All deploy and mod tests will fail ` + + `downstream with confusing extrinsic errors. Fix the funder or ` + + `set E2E_ALLOW_OFFLINE_SETUP=1 to skip chain-dependent tests.\n\n` + + `Underlying error: ${msg}`, + ); + } + } + + // Template registration is only consumed by one test (`dot mod` happy + // path), which uses `.skipIf(!TEST_DOMAIN)`. A failure here doesn't + // block the rest of the suite — log and continue. + try { await ensureTemplateRegistered(); } catch (err) { - console.warn(`[e2e setup] Chain setup failed (offline tests will still run): ${err}`); + console.warn(`[e2e setup] Template registration check failed: ${err}`); } }