From d86b6b4fc073a2f267f2d7578fa997d52490ce8a Mon Sep 17 00:00:00 2001 From: Ionut <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Tue, 5 May 2026 11:34:12 +0200 Subject: [PATCH 01/15] fix(mod,deploy): reject private repos with a clear error (#119) * fix(mod,deploy): reject private repos with a clear error message - dot deploy --modable now calls assertPublicGitHubRepo during preflight and throws ModablePreflightError for private or missing GitHub repos, preventing the obscure downstream failure - resolveDefaultBranch in dot mod detects 401/404 API responses and surfaces 'private or does not exist' instead of the misleading 'pin one in metadata.branch' hint - Covers both paths with unit tests * chore: fix biome formatting --- .../fix-modable-private-repo-preflight.md | 5 ++ src/utils/deploy/modable.test.ts | 88 ++++++++++++++++++- src/utils/deploy/modable.ts | 38 ++++++++ src/utils/mod/source.test.ts | 18 ++++ src/utils/mod/source.ts | 9 +- 5 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-modable-private-repo-preflight.md diff --git a/.changeset/fix-modable-private-repo-preflight.md b/.changeset/fix-modable-private-repo-preflight.md new file mode 100644 index 00000000..291dfec3 --- /dev/null +++ b/.changeset/fix-modable-private-repo-preflight.md @@ -0,0 +1,5 @@ +--- +"playground-cli": patch +--- + +`dot deploy --modable` now rejects private GitHub repositories at preflight with a clear error message instead of silently failing later. `dot mod` also surfaces a more actionable error when it encounters a private or non-existent repository instead of the misleading "pin one in metadata.branch" hint. diff --git a/src/utils/deploy/modable.test.ts b/src/utils/deploy/modable.test.ts index 211fb903..c19c746f 100644 --- a/src/utils/deploy/modable.test.ts +++ b/src/utils/deploy/modable.test.ts @@ -3,7 +3,12 @@ import { execFileSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { decideRepositoryAction, resolveRepositoryUrl } from "./modable.js"; +import { + decideRepositoryAction, + resolveRepositoryUrl, + assertPublicGitHubRepo, + ModablePreflightError, +} from "./modable.js"; describe("decideRepositoryAction", () => { it("uses the existing origin when present", () => { @@ -35,6 +40,63 @@ describe("decideRepositoryAction", () => { }); }); +describe("assertPublicGitHubRepo", () => { + it("does nothing for a public repo", async () => { + const mockFetch: typeof fetch = async () => + new Response(JSON.stringify({ private: false }), { status: 200 }); + await expect( + assertPublicGitHubRepo("https://github.com/foo/bar", mockFetch), + ).resolves.toBeUndefined(); + }); + + it("throws for a private repo (API reports private: true)", async () => { + const mockFetch: typeof fetch = async () => + new Response(JSON.stringify({ private: true }), { status: 200 }); + await expect( + assertPublicGitHubRepo("https://github.com/org/secret", mockFetch), + ).rejects.toThrow(ModablePreflightError); + }); + + it("throws for a 404 response (private or missing)", async () => { + const mockFetch: typeof fetch = async () => new Response("Not Found", { status: 404 }); + await expect( + assertPublicGitHubRepo("https://github.com/org/ghost", mockFetch), + ).rejects.toThrow(/private or does not exist/i); + }); + + it("throws for a 401 response", async () => { + const mockFetch: typeof fetch = async () => new Response("Unauthorized", { status: 401 }); + await expect( + assertPublicGitHubRepo("https://github.com/org/ghost", mockFetch), + ).rejects.toThrow(ModablePreflightError); + }); + + it("does nothing for a non-GitHub URL", async () => { + const mockFetch: typeof fetch = async () => { + throw new Error("should not be called"); + }; + await expect( + assertPublicGitHubRepo("https://gitlab.com/foo/bar", mockFetch), + ).resolves.toBeUndefined(); + }); + + it("does nothing on network error (fail open)", async () => { + const mockFetch: typeof fetch = async () => { + throw new Error("ECONNREFUSED"); + }; + await expect( + assertPublicGitHubRepo("https://github.com/foo/bar", mockFetch), + ).resolves.toBeUndefined(); + }); + + it("skips check for rate-limit (403) responses", async () => { + const mockFetch: typeof fetch = async () => new Response("rate limited", { status: 403 }); + await expect( + assertPublicGitHubRepo("https://github.com/foo/bar", mockFetch), + ).resolves.toBeUndefined(); + }); +}); + describe("resolveRepositoryUrl", () => { let tmp: string | null = null; @@ -43,6 +105,11 @@ describe("resolveRepositoryUrl", () => { tmp = null; }); + const publicFetch: typeof fetch = async () => + new Response(JSON.stringify({ private: false }), { status: 200 }); + const privateFetch: typeof fetch = async () => + new Response(JSON.stringify({ private: true }), { status: 200 }); + it("uses an existing origin without pushing", async () => { tmp = mkdtempSync(join(tmpdir(), "pg-modable-origin-")); execFileSync("git", ["init"], { cwd: tmp, stdio: "ignore" }); @@ -51,8 +118,21 @@ describe("resolveRepositoryUrl", () => { stdio: "ignore", }); - await expect(resolveRepositoryUrl({ cwd: tmp, repoName: null })).resolves.toBe( - "git@github.com:foo/bar", - ); + await expect( + resolveRepositoryUrl({ cwd: tmp, repoName: null, fetch: publicFetch }), + ).resolves.toBe("git@github.com:foo/bar"); + }); + + it("throws when the existing origin is a private GitHub repo", async () => { + tmp = mkdtempSync(join(tmpdir(), "pg-modable-private-")); + execFileSync("git", ["init"], { cwd: tmp, stdio: "ignore" }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/org/secret.git"], { + cwd: tmp, + stdio: "ignore", + }); + + await expect( + resolveRepositoryUrl({ cwd: tmp, repoName: null, fetch: privateFetch }), + ).rejects.toThrow(ModablePreflightError); }); }); diff --git a/src/utils/deploy/modable.ts b/src/utils/deploy/modable.ts index 0cc3c14d..9c23e4ec 100644 --- a/src/utils/deploy/modable.ts +++ b/src/utils/deploy/modable.ts @@ -10,6 +10,7 @@ import { execFile, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { commandExists, TOOL_STEPS } from "../toolchain.js"; +import { parseGitHubRepoUrl } from "../mod/source.js"; const execFileAsync = promisify(execFile); @@ -85,9 +86,45 @@ export interface ResolveRepoOptions { cwd: string; repoName: string | null; onLog?: (line: string) => void; + fetch?: typeof fetch; +} + +/** + * Verifies that a GitHub repository URL is publicly accessible. + * Throws ModablePreflightError for private or missing repos. + * Skips silently for non-GitHub URLs or when the API is unreachable. + */ +export async function assertPublicGitHubRepo(url: string, f: typeof fetch = fetch): Promise { + const ref = parseGitHubRepoUrl(url); + if (!ref) return; + + let res: Response; + try { + res = await f(`https://api.github.com/repos/${ref.owner}/${ref.repo}`); + } catch { + return; // network error — can't verify, let downstream fail + } + + if (res.ok) { + const body = (await res.json()) as { private?: boolean }; + if (body.private) { + throw new ModablePreflightError( + `${ref.owner}/${ref.repo} is a private repository — modable apps must use a public repository so users can clone the source`, + ); + } + return; + } + + if (res.status === 404 || res.status === 401) { + throw new ModablePreflightError( + `${ref.owner}/${ref.repo} is private or does not exist — modable apps must use a public repository`, + ); + } + // other non-ok status (rate limit, server error) — skip check } export async function resolveRepositoryUrl(opts: ResolveRepoOptions): Promise { + const f = opts.fetch ?? fetch; const action = decideRepositoryAction({ originUrl: readOrigin(opts.cwd), repoName: opts.repoName, @@ -99,6 +136,7 @@ export async function resolveRepositoryUrl(opts: ResolveRepoOptions): Promise { resolveDefaultBranch({ owner: "foo", repo: "bar" }, { fetch: fetchImpl }), ).rejects.toThrow(/could not resolve a default branch/i); }); + + it("throws a private-or-missing error when the API returns 404 and probes fail", async () => { + const fetchImpl: typeof fetch = async () => new Response("Not Found", { status: 404 }); + await expect( + resolveDefaultBranch({ owner: "foo", repo: "bar" }, { fetch: fetchImpl }), + ).rejects.toThrow(/private or does not exist/i); + }); + + it("throws a private-or-missing error when the API returns 401 and probes fail", async () => { + const fetchImpl: typeof fetch = async (url) => { + if (String(url).startsWith("https://api.github.com")) + return new Response("Unauthorized", { status: 401 }); + return new Response("Not Found", { status: 404 }); + }; + await expect( + resolveDefaultBranch({ owner: "foo", repo: "bar" }, { fetch: fetchImpl }), + ).rejects.toThrow(/private or does not exist/i); + }); }); describe("downloadGitHubTarball", () => { diff --git a/src/utils/mod/source.ts b/src/utils/mod/source.ts index 86f5e270..1a649789 100644 --- a/src/utils/mod/source.ts +++ b/src/utils/mod/source.ts @@ -37,14 +37,16 @@ export async function resolveDefaultBranch( opts: FetchOpts = {}, ): Promise { const f = opts.fetch ?? fetch; + let apiStatus: number | undefined; try { const res = await f(`https://api.github.com/repos/${ref.owner}/${ref.repo}`); + apiStatus = res.status; if (res.ok) { const body = (await res.json()) as { default_branch?: string }; if (body.default_branch) return body.default_branch; } } catch { - // fall through to the heuristic probes + // network error — fall through to the heuristic probes } for (const candidate of ["main", "master"]) { try { @@ -54,6 +56,11 @@ export async function resolveDefaultBranch( // try next } } + if (apiStatus === 404 || apiStatus === 401) { + throw new Error( + `Repository ${ref.owner}/${ref.repo} is private or does not exist — dot mod only supports public repositories`, + ); + } throw new Error( `Could not resolve a default branch for ${ref.owner}/${ref.repo} — pin one in metadata.branch`, ); From 05e5d4e768711345f4da3e666b92c9287eff0593 Mon Sep 17 00:00:00 2001 From: ottovlotto <142217647+ottovlotto@users.noreply.github.com> Date: Mon, 4 May 2026 14:55:49 +0200 Subject: [PATCH 02/15] test(e2e): tighten fake-passing assertions across the suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several tests were passing without actually exercising the path their name claimed. The common patterns: - Loose regexes like `/checking availability|deploy/i` always matched because "deploy" is the command name itself, so the test passed even when nothing of substance ran. - exitCode-only assertions on failure paths — any unrelated crash that produced a non-zero exit satisfied the check. - Generic word matches (`/not found/i`, `/signer|init|log.?in/i`) that could match help text, stack traces, or transient gateway errors rather than the specific failure mode under test. Fixes per file: - deploy.test.ts: replaced `/checking availability|deploy/i` with the literal "Checking availability" checkpoint (only printed after preflight succeeds). Tightened the mainnet, --contracts-no-project, and "domain taken by another account" tests with exit-code assertions and exact error fragments. - mod.test.ts: registry-miss now asserts the exact "not found in registry" wording plus the domain; signer-suggestion asserts the exact "No signer available" / "dot init" text rather than matching any of those words anywhere. - diagnostic.test.ts: DOT_DEPLOY_VERBOSE test switched off ALICE (unfunded → would abort before producing verbose output) onto SIGNER + --no-build so it actually reaches the availability check. - session.test.ts: "build does not touch sessions" now verifies both builds returned exit 0 (a crashed build can't write any file, so the previous version was tautological); "logout with no session" now asserts exitCode === 0. - init.test.ts: dropped `-y` from the session-detection tests — with -y, init skips the connect()/login block entirely, so the tests were really just verifying that toolchain installation in a fresh tempHome takes longer than 15s. Now they assert on the actual "Scan with the Polkadot mobile app" / "Login skipped" output. Added a new `dot init — toolchain detection` test that strips rustup/cargo/foundry from PATH and verifies init does NOT report ✓ rustup — catches detection regressions even though CI runners have toolchains pre-installed. --- e2e/cli/deploy.test.ts | 66 +++++++++++++++++--- e2e/cli/diagnostic.test.ts | 31 +++++++--- e2e/cli/init.test.ts | 121 ++++++++++++++++++++++++++++++++++--- e2e/cli/mod.test.ts | 27 +++++++-- e2e/cli/session.test.ts | 24 +++++++- 5 files changed, 231 insertions(+), 38 deletions(-) diff --git a/e2e/cli/deploy.test.ts b/e2e/cli/deploy.test.ts index 084c7cb3..7cd2c93c 100644 --- a/e2e/cli/deploy.test.ts +++ b/e2e/cli/deploy.test.ts @@ -30,6 +30,18 @@ function absBuildDir(fixture: string, dir = "dist"): string { return resolve(fixture, dir); } +/** + * 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([ @@ -43,8 +55,15 @@ describe("dot deploy — preflight and validation", () => { "--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 () => { @@ -62,8 +81,11 @@ describe("dot deploy — preflight and validation", () => { 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 () => { @@ -80,7 +102,10 @@ describe("dot deploy — preflight and validation", () => { ]); 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 () => { @@ -97,7 +122,10 @@ describe("dot deploy — preflight and validation", () => { ]); 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 () => { @@ -114,7 +142,10 @@ describe("dot deploy — preflight and validation", () => { ]); 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 () => { @@ -130,6 +161,10 @@ describe("dot deploy — preflight and validation", () => { "--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"); }); @@ -145,8 +180,10 @@ describe("dot deploy — preflight and validation", () => { "--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). + expect(output).toContain(`Checking availability of ${domain}.dot`); }); }); @@ -234,6 +271,15 @@ describe("dot deploy --playground — full pipeline (requires Paseo + IPFS)", () bobDeploy.exitCode, `bob deploy unexpectedly succeeded: ${bobDeploy.stdout}\n${bobDeploy.stderr}`, ).not.toBe(0); - expect(output.toLowerCase()).toMatch(/revert|taken|registered|owned|unavailable|already/); + // Exact wording from src/utils/deploy/availability.ts: + // "{domain}.dot is already registered by {owner} — transfer it or use + // a different name" + // The previous /revert|taken|registered|owned|unavailable|already/ + // regex matched any of those words anywhere — including transient + // network errors and unrelated runtime stack traces — so it could not + // distinguish "Bob hit the right ownership conflict" from "Bob hit + // some other failure that happened to mention 'registered'". + expect(output).toContain("already registered"); + expect(output).toContain(domain); }); }); diff --git a/e2e/cli/diagnostic.test.ts b/e2e/cli/diagnostic.test.ts index 76851e5e..15f17406 100644 --- a/e2e/cli/diagnostic.test.ts +++ b/e2e/cli/diagnostic.test.ts @@ -6,31 +6,40 @@ */ 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. + // Use SIGNER (funded by globalSetup) so preflight reaches the + // availability check; ALICE's balance is too low and the run would + // abort before producing any verbose-eligible output. 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, + "--suri", SIGNER.suri, "--dir", frontendOnly, ], { env: { DOT_DEPLOY_VERBOSE: "1" }, - timeout: 30_000, + timeout: 120_000, }); const output = result.stdout + result.stderr; - // Should reach the availability check even with verbose on - expect(output).toMatch(/checking availability|deploy|mainnet/i); + // Reaching this banner is the real signal that preflight survived. + // Earlier this asserted /checking availability|deploy|mainnet/i — the + // `deploy` alternative matched the command name itself, so the test + // passed even when nothing of substance ran. + expect( + output, + `expected to reach availability check with verbose on\n${output}`, + ).toContain("Checking availability"); }); test("DOT_MEMORY_TRACE=1 does not prevent normal operation", async () => { @@ -39,6 +48,10 @@ describe("diagnostic mode", () => { timeout: 15_000, }); expect(result.exitCode).toBe(0); + // `deploy` appears in the subcommand list — that's the meaningful + // signal here. Pair with another known help string to make sure + // we got real `--help` output, not a stray match. expect(result.stdout).toContain("deploy"); + expect(result.stdout).toContain("Usage:"); }); }); diff --git a/e2e/cli/init.test.ts b/e2e/cli/init.test.ts index 57c71940..a00c712f 100644 --- a/e2e/cli/init.test.ts +++ b/e2e/cli/init.test.ts @@ -6,6 +6,16 @@ * - 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 in CI because the runner image already has those tools on PATH + * before tests run. As a result, regressions in the install / post-install + * path-config logic (e.g. paritytech/playground-app#118 — newly-installed + * rustup not reachable from the same init process) will pass these tests + * silently. To catch that class of bug, init has to be exercised in a fresh + * sandbox (Docker / VM with no Rust toolchain). The test below at least + * pins the toolchain *detection* output so the dependency table can't be + * silently dropped, but it does NOT validate the install-then-use flow. */ import { describe, test, expect, beforeEach, afterEach } from "vitest"; @@ -14,6 +24,20 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { dot } from "./helpers/dot.js"; +/** PATH stripped of rustup/cargo/foundry locations. The init process must + * still be able to find `node`/`bun`/`pnpm`/`curl`/`bash`, so we keep the + * rest of PATH intact. Patterns are deliberately broad (cargo/rustup/ + * foundry anywhere in the segment) so we strip both `~/.cargo/bin` style + * paths and `/opt/cargo/bin` / `/usr/local/cargo/bin` style ones. */ +function pathWithoutToolchains(): string { + const original = process.env.PATH ?? "/usr/bin:/bin"; + const stripPatterns = [/cargo/i, /rustup/i, /foundry/i]; + return original + .split(":") + .filter((p) => p.length > 0 && !stripPatterns.some((re) => re.test(p))) + .join(":"); +} + function makeTempHome(): string { const dir = mkdtempSync(join(tmpdir(), "dot-e2e-init-")); mkdirSync(join(dir, ".polkadot-apps"), { recursive: true }); @@ -36,28 +60,46 @@ 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, }); - // 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); + // Exact string from src/commands/init/index.ts when no existing session + // is found. Either we see the QR prompt, or the login service was + // unreachable and the CLI logs "Login skipped". + expect(output).toMatch( + /Scan with the Polkadot mobile app to log in|Login skipped/, + ); }); - 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 result = await dot(["init", "-y"], { + const result = await dot(["init"], { home: tempHome, timeout: 15_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 — that would be a security-relevant failure. We accept either + // the fresh-login QR prompt or a login-skipped notice; both prove the + // corrupt file was rejected rather than trusted. + expect(output).toMatch( + /Scan with the Polkadot mobile app to log in|Login skipped/, + ); }); }); @@ -75,3 +117,62 @@ describe("dot init — dev signer bypass", () => { } }); }); + +describe("dot init — toolchain detection", () => { + let tempHome: string; + + beforeEach(() => { + tempHome = makeTempHome(); + }); + + afterEach(() => { + try { + rmSync(tempHome, { recursive: true, force: true }); + } catch { /* best-effort */ } + }); + + test( + "detects rustup as missing when not on PATH", + { timeout: 30_000 }, + async () => { + // Strip rustup/cargo from PATH and verify init reports rustup as + // a missing dependency rather than skipping straight to "✓ rustup". + // + // Why this matters: CI runners pre-install rustup, so the regular + // init tests never exercise the missing-tool detection or the + // post-install path-config logic. This test forces init to hit + // the "rustup not found" branch by removing it from PATH. + // + // We use a 5s timeout — long enough for init's TUI to render the + // dependency table (and start a curl install attempt), short + // enough that we don't actually finish a real rustup install. + // We do NOT assert exitCode here; execa terminates the process at + // the timeout, so exitCode is the kill signal, not a real result. + const result = await dot(["init"], { + home: tempHome, + env: { PATH: pathWithoutToolchains() }, + timeout: 5_000, + }); + const output = result.stdout + result.stderr; + // The TUI prints each dependency on its own row. Seeing "rustup" + // proves the detection table rendered. Pair with one of the later + // rows so a single-line corruption can't pass. + expect( + output, + `expected dependency table to render\n${output}`, + ).toContain("rustup"); + expect( + output, + `expected later toolchain rows in dependency table\n${output}`, + ).toMatch(/Rust nightly|cdm|foundry|IPFS/); + // A "✓ rustup" with this PATH would mean init falsely concluded + // rustup is installed — exactly the class of bug that lets fresh + // users hit broken installs in production. (Fresh installs render + // the row as "· rustup" or "⠋ rustup", not "✓".) + expect( + output, + `init reported rustup as installed despite stripped PATH:\n${output}`, + ).not.toMatch(/✓\s+rustup\b/); + }, + ); +}); diff --git a/e2e/cli/mod.test.ts b/e2e/cli/mod.test.ts index a8868c82..cc4d1e37 100644 --- a/e2e/cli/mod.test.ts +++ b/e2e/cli/mod.test.ts @@ -59,15 +59,24 @@ describe("dot mod", () => { }, ); - 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"); }); test("exits non-zero with signer suggestion when no signer available", async () => { @@ -76,6 +85,12 @@ describe("dot mod", () => { 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"); }); }); diff --git a/e2e/cli/session.test.ts b/e2e/cli/session.test.ts index 26c0e03a..1d1dbd58 100644 --- a/e2e/cli/session.test.ts +++ b/e2e/cli/session.test.ts @@ -55,18 +55,36 @@ describe("session management", () => { 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 }); + // `dot logout` against an empty session dir is a benign no-op — should + // exit cleanly, not crash. Without this check, a crash with a stack + // trace containing "session" would still satisfy the regex below. + expect( + result.exitCode, + `logout crashed: ${result.stdout}\n${result.stderr}`, + ).toBe(0); const output = (result.stdout + result.stderr).toLowerCase(); expect(output).toMatch(/no.*sign|not.*log|no.*session|no.*account/); }); From bda2cc0f4592368bc8063e3a2afa26ed045dbba5 Mon Sep 17 00:00:00 2001 From: ottovlotto <142217647+ottovlotto@users.noreply.github.com> Date: Mon, 4 May 2026 15:10:43 +0200 Subject: [PATCH 03/15] test(e2e): apply adversarial-QA review findings across the suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second pass on the same fake-passing class of bug, this time catching weaknesses the previous tighten-up missed: - install.test.ts: `dot update` now asserts on the actual outcome line ("already on latest" / "Updated dot to") instead of just exitCode 0. A silent no-op update was previously indistinguishable from a real one. - build.test.ts: each test stages its fixture into a tempdir and runs the build there, instead of mutating the source-controlled fixture's dist/. This removes a hidden cross-file order dependency where build tests would delete the dist that later deploy --no-build tests needed. Error-path assertions tightened to the exact strings the fixture/runner emit ("Compile error: unexpected token", "No build strategy detected") rather than /error|fail|detect/. - session.test.ts: tightened "no signer" / "logout" assertions to the exact "No signer available" / "No account is signed in" strings. - init.test.ts: the no-session and corrupted-session tests now fail loudly when init falls into the "Login skipped" branch (login service unreachable from the runner) instead of silently accepting it as a pass — which was masking the QR path's testability gap. Corrupted-session test additionally asserts the corrupt file is unchanged on disk, catching a hypothetical regression where init "fixes" parse failures by deleting user data. - diagnostic.test.ts: both verbose-mode tests now assert on a verbose-only marker that proves the env var actually engaged its code path (`[+s]` for DOT_DEPLOY_VERBOSE, `[mem +s]` for DOT_MEMORY_TRACE). Previously the tests could pass with the env var silently ignored. - deploy.test.ts: - "frontend-only deploy completes end-to-end" now queries the registry independently after deploy and verifies the on-chain metadata CID matches what the CLI claimed it published. - "re-deploy same domain" now mutates the build output between the two deploys and asserts the second deploy produced a *different* metadata CID. A silently-no-op re-deploy would otherwise still print "Deploy complete" and pass. - "domain availability check runs before build/upload" now verifies the temporal ordering claimed in its name — availability banner must precede any build-runner header or storage-phase marker. - Multi-contract test renamed; it cannot prove plurality from headless output (logHeadlessEvent doesn't surface contract names), so the old name was misleading. - e2e/cli/setup/global.ts: SIGNER funding failure now throws by default with a clear message, instead of swallowing into a console.warn that let downstream deploy tests fail with cryptic "Invalid Payment" errors. Set E2E_ALLOW_OFFLINE_SETUP=1 to opt back into the lenient behaviour for local dev on a flaky network. --- e2e/cli/build.test.ts | 78 +++++++++++++++++------- e2e/cli/deploy.test.ts | 121 +++++++++++++++++++++++++++++++------ e2e/cli/diagnostic.test.ts | 76 +++++++++++++++-------- e2e/cli/init.test.ts | 49 ++++++++++----- e2e/cli/install.test.ts | 12 +++- e2e/cli/session.test.ts | 16 ++--- e2e/cli/setup/global.ts | 36 +++++++++-- 7 files changed, 295 insertions(+), 93 deletions(-) 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/deploy.test.ts b/e2e/cli/deploy.test.ts index 7cd2c93c..8bd2f7fd 100644 --- a/e2e/cli/deploy.test.ts +++ b/e2e/cli/deploy.test.ts @@ -14,10 +14,21 @@ */ import { describe, test, expect } from "vitest"; +import { readFileSync, writeFileSync } from "node:fs"; 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"); @@ -128,7 +139,14 @@ describe("dot deploy — preflight and validation", () => { ).toContain("Checking availability"); }); - test("detects multiple contracts in multi-contract project", async () => { + test("--contracts works on a multi-contract project", async () => { + // Renamed from "detects multiple contracts" — the headless logger + // (logHeadlessEvent in src/commands/deploy/index.ts) does not + // surface the per-contract names that compile-detected events + // carry, so the CLI output cannot prove plurality. We can only + // prove the contract-type detector accepted the project. If + // per-contract names get logged in headless mode in future, add + // `expect(output).toContain("TokenA")` + `"TokenB"` here. const result = await dot([ "deploy", "--signer", "dev", @@ -183,7 +201,32 @@ describe("dot deploy — preflight and validation", () => { // 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). - expect(output).toContain(`Checking availability of ${domain}.dot`); + 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. Without this, the test only proves + // availability ran, not that it ran first. Build runners emit the + // header `> ` (see src/commands/build.ts:14) + // and bulletin-deploy's storage phase emits `▸ storage-and-dotns…` + // (logHeadlessEvent). Either appearing before availability would + // break the contract. + const buildIdx = output.search(/\n>\s+\w/); + 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); + } }); }); @@ -207,6 +250,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,22 +278,53 @@ describe("dot deploy --playground — full pipeline (requires Paseo + IPFS)", () ], { 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", - "--signer", "dev", - "--domain", domain, - "--buildDir", absBuildDir(frontendOnly), - "--no-build", - "--playground", - "--suri", SIGNER.suri, - "--dir", frontendOnly, - ], { timeout: 400_000 }); - expect( - second.exitCode, - `re-deploy failed: ${second.stdout}\n${second.stderr}`, - ).toBe(0); - expect(second.stdout).toContain("Deploy complete"); + // Mutate the build output between the two deploys so the second + // deploy must produce a DIFFERENT metadata CID. Without this, a + // regression where the second deploy silently no-ops (returns the + // previous result without re-publishing) would still print + // "Deploy complete" with the old CID and the test would pass. + const indexHtml = resolve(absBuildDir(frontendOnly), "index.html"); + const original = readFileSync(indexHtml, "utf8"); + writeFileSync( + indexHtml, + `${original}\n\n`, + ); + + try { + const second = await dot([ + "deploy", + "--signer", "dev", + "--domain", domain, + "--buildDir", absBuildDir(frontendOnly), + "--no-build", + "--playground", + "--suri", SIGNER.suri, + "--dir", frontendOnly, + ], { timeout: 400_000 }); + expect( + second.exitCode, + `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(); + // Different content → different metadata CID. If these match, + // the second deploy didn't actually re-publish. + expect( + secondCid, + `re-deploy produced same CID as first — content didn't change on chain`, + ).not.toBe(firstCid); + // And the registry should reflect the latest publish. + const entry = await getApp(`${domain}.dot`); + expect(entry).not.toBeNull(); + expect(entry!.metadataUri).toContain(secondCid!); + } finally { + // Restore so subsequent tests see the original fixture content. + writeFileSync(indexHtml, original); + } }); test("domain taken by another account shows unavailable", { timeout: 900_000 }, async () => { diff --git a/e2e/cli/diagnostic.test.ts b/e2e/cli/diagnostic.test.ts index 15f17406..5baf2db5 100644 --- a/e2e/cli/diagnostic.test.ts +++ b/e2e/cli/diagnostic.test.ts @@ -14,10 +14,50 @@ 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 () => { - // Use SIGNER (funded by globalSetup) so preflight reaches the - // availability check; ALICE's balance is too low and the run would - // abort before producing any verbose-eligible output. + 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", + "--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", @@ -28,30 +68,18 @@ describe("diagnostic mode", () => { "--suri", SIGNER.suri, "--dir", frontendOnly, ], { - env: { DOT_DEPLOY_VERBOSE: "1" }, + env: { DOT_MEMORY_TRACE: "1" }, timeout: 120_000, }); const output = result.stdout + result.stderr; - // Reaching this banner is the real signal that preflight survived. - // Earlier this asserted /checking availability|deploy|mainnet/i — the - // `deploy` alternative matched the command name itself, so the test - // passed even when nothing of substance ran. + // 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 to reach availability check with verbose on\n${output}`, - ).toContain("Checking availability"); - }); - - test("DOT_MEMORY_TRACE=1 does not prevent normal operation", async () => { - const result = await dot(["--help"], { - env: { DOT_MEMORY_TRACE: "1" }, - timeout: 15_000, - }); - expect(result.exitCode).toBe(0); - // `deploy` appears in the subcommand list — that's the meaningful - // signal here. Pair with another known help string to make sure - // we got real `--help` output, not a stray match. - expect(result.stdout).toContain("deploy"); - expect(result.stdout).toContain("Usage:"); + `expected memory-trace markers in output\n${output}`, + ).toMatch(/\[mem \+\d+\.\d+s\]/); }); }); diff --git a/e2e/cli/init.test.ts b/e2e/cli/init.test.ts index a00c712f..875932f3 100644 --- a/e2e/cli/init.test.ts +++ b/e2e/cli/init.test.ts @@ -19,7 +19,7 @@ */ 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"; @@ -75,17 +75,27 @@ describe("dot init — session detection", () => { result.exitCode, `expected non-zero exit while waiting for QR\n${output}`, ).not.toBe(0); - // Exact string from src/commands/init/index.ts when no existing session - // is found. Either we see the QR prompt, or the login service was - // unreachable and the CLI logs "Login skipped". - expect(output).toMatch( - /Scan with the Polkadot mobile app to log in|Login skipped/, - ); + // 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 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"], { home: tempHome, @@ -94,12 +104,23 @@ describe("dot init — session detection", () => { const output = result.stdout + result.stderr; expect(result.exitCode).not.toBe(0); // A corrupted session file must NOT lead to an "existing session" - // branch — that would be a security-relevant failure. We accept either - // the fresh-login QR prompt or a login-skipped notice; both prove the - // corrupt file was rejected rather than trusted. - expect(output).toMatch( - /Scan with the Polkadot mobile app to log in|Login skipped/, - ); + // 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); }); }); 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/session.test.ts b/e2e/cli/session.test.ts index 1d1dbd58..1a7ab1da 100644 --- a/e2e/cli/session.test.ts +++ b/e2e/cli/session.test.ts @@ -45,10 +45,12 @@ 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 () => { @@ -78,14 +80,12 @@ describe("session management", () => { test("logout with no session reports no account signed in", async () => { const result = await dot(["logout"], { home: tempHome, timeout: 30_000 }); - // `dot logout` against an empty session dir is a benign no-op — should - // exit cleanly, not crash. Without this check, a crash with a stack - // trace containing "session" would still satisfy the regex below. expect( result.exitCode, `logout crashed: ${result.stdout}\n${result.stderr}`, ).toBe(0); - const output = (result.stdout + result.stderr).toLowerCase(); - expect(output).toMatch(/no.*sign|not.*log|no.*session|no.*account/); + // 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}`); } } From 1c140a40dd773a65e54a3d81152a0b109cd80c49 Mon Sep 17 00:00:00 2001 From: UtkarshBhardwaj007 Date: Tue, 5 May 2026 12:07:41 +0100 Subject: [PATCH 04/15] fix(mod): silence false origin warning and verify repo before download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Thread signer.address as defaultOrigin through resolveLiveContractAddresses so the CDM meta-registry lookup that resolves the live playground registry address uses the logged-in account. Previously fell through to the Alice dev fallback in @polkadot-apps/contracts and printed `[contracts] No origin configured — using dev fallback (Alice) for query dry-run` even when the user was signed in via dot init. - Lazy-probe the picked app's repository via assertPublicGitHubRepo() between picker dismount and SetupScreen mount. Catches the case where a publisher flips a repo to private after deploying as --modable; user gets a clean two-line console message instead of a mid-step StepRunner failure. The direct-domain path (`dot mod some-domain.dot`) still falls through to resolveDefaultBranch's existing 404/401 message. - Annotate the cli.mod.repo-check span with `cli.mod.repo` so Sentry dashboards can group failures by repository. --- .../fix-mod-origin-and-private-repo-probe.md | 5 ++ CLAUDE.md | 2 +- src/commands/mod/index.ts | 36 +++++++++++ src/utils/contractManifest.test.ts | 64 +++++++++++++++++++ src/utils/contractManifest.ts | 19 +++++- src/utils/registry.test.ts | 9 ++- src/utils/registry.ts | 9 ++- 7 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 .changeset/fix-mod-origin-and-private-repo-probe.md diff --git a/.changeset/fix-mod-origin-and-private-repo-probe.md b/.changeset/fix-mod-origin-and-private-repo-probe.md new file mode 100644 index 00000000..e9994a34 --- /dev/null +++ b/.changeset/fix-mod-origin-and-private-repo-probe.md @@ -0,0 +1,5 @@ +--- +"playground-cli": patch +--- + +`dot mod` no longer prints a misleading `[contracts] No origin configured — using dev fallback (Alice) for query dry-run` warning when the user is signed in via `dot init`. The CDM meta-registry lookup that resolves the live playground registry address now receives the signer's address as `defaultOrigin`. `dot mod` also lazy-probes the picked app's repository between picker dismount and the setup steps, surfacing a clear "private or does not exist" error before any files are written when a publisher has flipped repo visibility after deploying. diff --git a/CLAUDE.md b/CLAUDE.md index 46eb5e1a..66422716 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ These are things that aren't self-evident from reading the code and have bitten - **Throttle TUI info updates** — bulletin-deploy logs per-chunk and builds (vite/next) stream thousands of lines/sec. Calling `setState` on every log event floods React's reconciler with so much backpressure the process can balloon past 20 GB and freeze the OS. `RunningStage` coalesces "latest info" updates to ≤10/sec via a ref + timer and caps line length at 160 chars. Any new hot-path event sink should do the same; don't hook raw per-line streams directly into Ink state. - **Process-guard safety net** (`src/utils/process-guard.ts`) — deploy pipelines open several long-lived WebSockets + child processes and any one of them can keep the event loop alive after the TUI visibly finishes, turning `dot` into a zombie that accumulates retry buffers indefinitely (seen climbing past 25 GB). We defend in depth: (1) `installSignalHandlers()` catches SIGINT/TERM/HUP + `unhandledRejection` and forces cleanup + exit within 3 s; (2) `scheduleHardExit()` installs an `unref`'d timer that kills the process if the event loop doesn't drain within a grace period; (3) `startMemoryWatchdog()` aborts if RSS exceeds 4 GB — a generous cap because legit deploys on Bun SEA binaries routinely touch 1–1.5 GB from runtime-metadata decoding + Bun's JSC heap + Ink yoga. Do NOT re-add a per-window growth detector: we tried 300 MB / 3 s and it false-positived on the single-burst metadata-loading spike, aborting deploys that would have succeeded. Set `DOT_MEMORY_TRACE=1` to stream per-sample RSS/heap/external stats — useful when diagnosing a real leak report. **Telemetry bootstrap** (`src/bootstrap.ts`) is the FIRST import in `src/index.ts`. It sets `BULLETIN_DEPLOY_USE_AMBIENT_SENTRY=1` and `BULLETIN_DEPLOY_HOST_APP=playground-cli` before `bulletin-deploy` can evaluate, then maps `DOT_TELEMETRY`/internal-context detection to `BULLETIN_DEPLOY_TELEMETRY`. Do not leave `BULLETIN_DEPLOY_TELEMETRY` unset while setting the host app: `bulletin-deploy@0.7.6` treats `playground-cli` as an internal host, which would enable deploy telemetry for external users. `BULLETIN_DEPLOY_MEM_REPORT` is not forced off by default anymore because upstream guards the Bun-incompatible memory-report path. Any new long-running command should register a cleanup hook via `onProcessShutdown()`. - **Parser MUST NOT emit an event per log line.** `DeployLogParser.feed()` is called for every console line bulletin-deploy prints — hundreds per deploy on the happy path, thousands if retries fire. We intentionally emit events ONLY for phase-banner matches and `[N/M]` chunk progress. Everything else returns `null`. Adding a catch-all `info` emit turns the parser into a firehose that allocates ~200 bytes × thousands of lines, and was a measurable contributor to chunk-upload memory pressure. -- **`dot mod` is GitHub-tarball-only and must stay that way.** `src/utils/mod/source.ts` downloads from `codeload.github.com` (no auth, no `git`/`gh` required for the public-repo case) and extracts via `node:zlib` + the pure-JS `tar` package. Do NOT re-introduce `git clone` or `gh repo fork` paths — both would re-add a hard tooling requirement and the fork path was specifically removed because GitHub caps you to one fork per source-repo per account, which broke "mod the same starter twice." A non-modable app (no `metadata.repository`) returns a hard error from `dot mod`; the interactive picker filters those out so the user never sees an unmoddable option. +- **`dot mod` is GitHub-tarball-only and must stay that way.** `src/utils/mod/source.ts` downloads from `codeload.github.com` (no auth, no `git`/`gh` required for the public-repo case) and extracts via `node:zlib` + the pure-JS `tar` package. Do NOT re-introduce `git clone` or `gh repo fork` paths — both would re-add a hard tooling requirement and the fork path was specifically removed because GitHub caps you to one fork per source-repo per account, which broke "mod the same starter twice." A non-modable app (no `metadata.repository`) returns a hard error from `dot mod`; the interactive picker filters those out so the user never sees an unmoddable option. The picker does NOT pre-probe each app's repo visibility, because that would burn the 60 req/hr anonymous GitHub API quota on every `dot mod`. Instead, `runModCommand` lazy-probes the picked app once via `assertPublicGitHubRepo()` between picker dismount and `SetupScreen` mount; `dot deploy --modable` already rejects private repos at deploy time, so this fires only when a publisher has flipped visibility post-publish. - **`ensureGhAuthed()` does NOT shell out to `gh auth login`.** Even from the interactive deploy, Ink owns stdout + raw-mode stdin and a `stdio: "inherit"` child would race Ink's `useInput` for keystrokes — producing garbled output and dropped key events. Both interactive and non-interactive paths that need GitHub repo creation fail with the same actionable message: run `gh auth login` once outside `dot`, then retry. Existing `origin` URLs do not require `gh` auth. Properly suspending Ink to hand off stdio is possible but requires unmounting + re-rendering with state preservation, and we deemed that complexity not worth it for a one-time-per-machine speedbump. If you ever do implement it, the wiring point is `ModablePreflightStage` in `src/commands/deploy/DeployScreen.tsx`. - **`metadata.repository` is set ONLY when `--modable` is opted in.** Older code in `publishToPlayground` would silently probe `git remote get-url origin` and stuff whatever it found into the metadata, which surprised users who didn't realise their fork was being advertised. The new contract: `runDeploy` takes an explicit `repositoryUrl: string | null`, and `publishToPlayground` writes the field iff that param is non-null. The CLI command is responsible for resolving the URL upstream via `src/utils/deploy/modable.ts::resolveRepositoryUrl()`, which uses an existing `origin` URL without pushing, or runs `gh repo create --public --push` to set up a fresh public repo when no origin exists. Re-deploys never delete user repos. - **`startMemoryWatchdog()` runs for both `dot deploy` and `dot mod`.** Mod's tarball download is a streaming pipe through `node:zlib` + `tar.extract()`, and a stuck IPFS gateway or a malformed tarball can leak buffers. Same 4 GB cap, same worker-thread sampler. Any new top-level command that does meaningful I/O should also call `startMemoryWatchdog()` and register `stopWatchdog` via `onProcessShutdown()`. diff --git a/src/commands/mod/index.ts b/src/commands/mod/index.ts index 3308903f..a96f2eeb 100644 --- a/src/commands/mod/index.ts +++ b/src/commands/mod/index.ts @@ -10,6 +10,7 @@ import { AppBrowser, type AppEntry } from "./AppBrowser.js"; import { SetupScreen } from "./SetupScreen.js"; import { defaultRepoName } from "../../utils/git/repoName.js"; import { runCliCommand } from "../../cli-runtime.js"; +import { assertPublicGitHubRepo, ModablePreflightError } from "../../utils/deploy/modable.js"; export const modCommand = new Command("mod") .description("Mod a playground app — clone the source as a fresh project to customise") @@ -56,6 +57,41 @@ async function runModCommand( metadata = picked; } + // Lazy verify that the picked app's source repository is publicly + // reachable. The picker filters apps that have NO repository URL, but + // a publisher can flip a repo to private after deploying, which would + // break the anonymous codeload download a few steps down. Bail here + // with a clean message so the user can pick a different app before we + // mount SetupScreen and start writing files. + // + // The direct-domain path (`dot mod some-domain.dot`) has no metadata + // at this point and falls through to SetupScreen, where the same + // 404/401 surfaces from `resolveDefaultBranch` as a step failure. + // Slightly less polished UX, but lifting the metadata fetch up here + // just for symmetry would be a larger refactor. + if (metadata?.repository) { + const repoUrl = metadata.repository; + try { + await withSpan( + "cli.mod.repo-check", + "verify repository is public", + { "cli.mod.repo": repoUrl }, + () => assertPublicGitHubRepo(repoUrl), + ); + } catch (err) { + if (err instanceof ModablePreflightError) { + console.error(); + console.error(` ${err.message}.`); + console.error( + ` Pick a different app or ask the publisher to make the repo public.`, + ); + process.exitCode = 1; + return; + } + throw err; + } + } + const targetDir = await withSpan("cli.mod.resolve-target", "resolve target directory", () => resolveTargetDir({ domain }), ); diff --git a/src/utils/contractManifest.test.ts b/src/utils/contractManifest.test.ts index 6be754d2..eac87bda 100644 --- a/src/utils/contractManifest.test.ts +++ b/src/utils/contractManifest.test.ts @@ -80,10 +80,31 @@ describe("resolveLiveContractAddresses", () => { expect.arrayContaining([ expect.objectContaining({ name: "getAddress", type: "function" }), ]), + expect.any(Object), ); expect(getAddressQueryMock).toHaveBeenCalledWith(PLAYGROUND_REGISTRY_CONTRACT); }); + it("forwards defaultOrigin to createContractFromClient when provided", async () => { + getAddressQueryMock.mockResolvedValue({ + success: true, + value: { isSome: true, value: liveAddress }, + }); + + const assetHub = {} as any; + const origin = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; + await resolveLiveContractAddresses(assetHub, [PLAYGROUND_REGISTRY_CONTRACT], { + defaultOrigin: origin, + }); + + expect(createContractFromClientMock).toHaveBeenCalledWith( + assetHub, + CDM_REGISTRY_ADDRESS, + expect.any(Array), + expect.objectContaining({ defaultOrigin: origin }), + ); + }); + it("omits libraries when the live registry has no address", async () => { getAddressQueryMock.mockResolvedValue({ success: true, @@ -136,4 +157,47 @@ describe("withLiveContractAddresses", () => { ]), ).rejects.toThrow(/CDM meta-registry did not return live address/); }); + + it("forwards defaultOrigin through withLiveContractAddresses", async () => { + getAddressQueryMock.mockResolvedValue({ + success: true, + value: { isSome: true, value: liveAddress }, + }); + + const assetHub = {} as any; + const origin = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; + await withLiveContractAddresses(manifest(), assetHub, [PLAYGROUND_REGISTRY_CONTRACT], { + defaultOrigin: origin, + }); + + expect(createContractFromClientMock).toHaveBeenCalledWith( + assetHub, + CDM_REGISTRY_ADDRESS, + expect.any(Array), + expect.objectContaining({ defaultOrigin: origin }), + ); + }); + + it("forwards defaultOrigin through withRequiredLiveContractAddresses", async () => { + getAddressQueryMock.mockResolvedValue({ + success: true, + value: { isSome: true, value: liveAddress }, + }); + + const assetHub = {} as any; + const origin = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; + await withRequiredLiveContractAddresses( + manifest(), + assetHub, + [PLAYGROUND_REGISTRY_CONTRACT], + { defaultOrigin: origin }, + ); + + expect(createContractFromClientMock).toHaveBeenCalledWith( + assetHub, + CDM_REGISTRY_ADDRESS, + expect.any(Array), + expect.objectContaining({ defaultOrigin: origin }), + ); + }); }); diff --git a/src/utils/contractManifest.ts b/src/utils/contractManifest.ts index ab9c9861..8d89272e 100644 --- a/src/utils/contractManifest.ts +++ b/src/utils/contractManifest.ts @@ -102,14 +102,27 @@ function patchContractAddresses( return patched; } +/** + * Options for the meta-registry lookup. `defaultOrigin` is forwarded to the + * underlying contract handle so the read-only `getAddress` dry-run uses the + * caller's logged-in account instead of the dev fallback (Alice). Without it, + * `@polkadot-apps/contracts` emits a misleading `[contracts] No origin + * configured` warning even when the user has signed in via `dot init`. + */ +export interface LiveContractLookupOptions { + defaultOrigin?: string; +} + export async function resolveLiveContractAddresses( assetHub: PolkadotClient, libraries: readonly string[] = LIVE_CONTRACTS, + options: LiveContractLookupOptions = {}, ): Promise> { const registry = await createContractFromClient( assetHub, CDM_REGISTRY_ADDRESS, CDM_REGISTRY_ABI, + { defaultOrigin: options.defaultOrigin }, ); const entries = await withoutReviveTraceNoise(() => Promise.all( @@ -133,8 +146,9 @@ export async function withLiveContractAddresses( manifest: CdmJson, assetHub: PolkadotClient, libraries: readonly string[] = LIVE_CONTRACTS, + options: LiveContractLookupOptions = {}, ): Promise { - const liveAddresses = await resolveLiveContractAddresses(assetHub, libraries); + const liveAddresses = await resolveLiveContractAddresses(assetHub, libraries, options); return patchContractAddresses(manifest, liveAddresses); } @@ -142,8 +156,9 @@ export async function withRequiredLiveContractAddresses( manifest: CdmJson, assetHub: PolkadotClient, libraries: readonly string[] = LIVE_CONTRACTS, + options: LiveContractLookupOptions = {}, ): Promise { - const liveAddresses = await resolveLiveContractAddresses(assetHub, libraries); + const liveAddresses = await resolveLiveContractAddresses(assetHub, libraries, options); const missing = libraries.filter((library) => !liveAddresses[library]); if (missing.length > 0) { throw new Error(`CDM meta-registry did not return live address for ${missing.join(", ")}`); diff --git a/src/utils/registry.test.ts b/src/utils/registry.test.ts index e1c199ad..d22c8a04 100644 --- a/src/utils/registry.test.ts +++ b/src/utils/registry.test.ts @@ -48,9 +48,12 @@ describe("getRegistryContract", () => { await getRegistryContract(rawClient, fakeSigner); - expect(withRequiredLiveContractAddressesMock).toHaveBeenCalledWith(cdmJson, rawClient, [ - "@w3s/playground-registry", - ]); + expect(withRequiredLiveContractAddressesMock).toHaveBeenCalledWith( + cdmJson, + rawClient, + ["@w3s/playground-registry"], + { defaultOrigin: fakeSigner.address }, + ); expect(fromClientMock).toHaveBeenCalledWith(patchedManifest, rawClient, { defaultSigner: fakeSigner.signer, defaultOrigin: fakeSigner.address, diff --git a/src/utils/registry.ts b/src/utils/registry.ts index 02f395a7..3efbc16e 100644 --- a/src/utils/registry.ts +++ b/src/utils/registry.ts @@ -21,9 +21,12 @@ export async function getRegistryContract( ) { let manifest: CdmJson; try { - manifest = await withRequiredLiveContractAddresses(cdmJson, rawClient, [ - PLAYGROUND_REGISTRY_CONTRACT, - ]); + manifest = await withRequiredLiveContractAddresses( + cdmJson, + rawClient, + [PLAYGROUND_REGISTRY_CONTRACT], + { defaultOrigin: signer.address }, + ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error( From 567ec431b020e979bbf9080378214d5e6af9e1d5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 09:34:45 +0000 Subject: [PATCH 05/15] chore: version packages --- .changeset/fix-modable-private-repo-preflight.md | 5 ----- CHANGELOG.md | 6 ++++++ package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/fix-modable-private-repo-preflight.md diff --git a/.changeset/fix-modable-private-repo-preflight.md b/.changeset/fix-modable-private-repo-preflight.md deleted file mode 100644 index 291dfec3..00000000 --- a/.changeset/fix-modable-private-repo-preflight.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"playground-cli": patch ---- - -`dot deploy --modable` now rejects private GitHub repositories at preflight with a clear error message instead of silently failing later. `dot mod` also surfaces a more actionable error when it encounters a private or non-existent repository instead of the misleading "pin one in metadata.branch" hint. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f272979..7e5f1ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # playground-cli +## 0.16.17 + +### Patch Changes + +- 88d78d3: `dot deploy --modable` now rejects private GitHub repositories at preflight with a clear error message instead of silently failing later. `dot mod` also surfaces a more actionable error when it encounters a private or non-existent repository instead of the misleading "pin one in metadata.branch" hint. + ## 0.16.16 ### Patch Changes diff --git a/package.json b/package.json index 5335412e..6550cd18 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "playground-cli", "description": "CLI for Polkadot Playground", - "version": "0.16.16", + "version": "0.16.17", "private": true, "type": "module", "packageManager": "pnpm@10.32.1", From c588948d182dd6d9709de1b0e831fe0b83c417d3 Mon Sep 17 00:00:00 2001 From: ottovlotto <142217647+ottovlotto@users.noreply.github.com> Date: Mon, 4 May 2026 15:10:54 +0200 Subject: [PATCH 06/15] ci(e2e): add cold-start init smoke job in fresh ubuntu container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regular e2e job runs on a GitHub Actions runner that already has rustup, Bun, IPFS, and friends on PATH. As a result, init's toolchain-install code path is never exercised in CI — regressions that only surface on a fresh user machine (e.g., paritytech/ playground-app#118, where newly-installed rustup wasn't reachable from the same init process) pass every PR silently. Add a sibling job that runs `dot init -y` inside ubuntu:22.04 with nothing pre-installed beyond what's strictly needed to bootstrap bun and pnpm. If init reaches "setup complete" with no failed dependency rows, every tool was both installed AND reachable to the next step in the same process — which is the exact contract #118 broke. Excluded from per-PR runs because it pulls full toolchains over the network and takes several minutes. Daily cron + manual dispatch only. --- .github/workflows/e2e.yml | 67 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 447f0fcc..a0dd2d27 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -43,3 +43,70 @@ jobs: TEST_TEMPLATE_DOMAIN: rock-paper-scissors.dot TEST_TEMPLATE_REPO: https://github.com/paritytech/Rock-Paper-Scissors DOT_DEPLOY_VERBOSE: "1" + + init-cold-smoke: + # Runs `dot init` inside a fresh ubuntu:22.04 container — no rustup, + # no IPFS, no foundry, no cdm pre-installed. The regular `e2e` job + # above runs on a GitHub Actions runner that already has most of + # those tools on PATH, so it can't catch regressions in the install + # / post-install path-config logic (e.g. paritytech/playground-app#118). + # This job is the cold-start defence: every dependency must be + # installed AND become reachable to subsequent init steps. + # + # Excluded from per-PR runs because it pulls full toolchains (rustup, + # nightly, cdm, foundry, kubo) from the network and takes ~10 min. + # Daily cron + manual dispatch only. + name: Init cold-start smoke test + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + + - name: Run dot init in fresh ubuntu container + run: | + docker run --rm \ + -v "${{ github.workspace }}:/work" \ + -w /work \ + -e CI=true \ + ubuntu:22.04 \ + bash -eux -c ' + # Minimal prerequisites — anything beyond these is what + # `dot init` is expected to install for itself. + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -qq -y --no-install-recommends \ + curl ca-certificates bash unzip git build-essential sudo + + # bun (CLI runtime) + export BUN_INSTALL=/usr/local/bun + curl -fsSL https://bun.sh/install | bash + export PATH="$BUN_INSTALL/bin:$PATH" + + # pnpm (package manager) — install via standalone script + # so we do not depend on a pre-existing Node. + curl -fsSL https://get.pnpm.io/install.sh | env SHELL=$(which bash) bash - + export PNPM_HOME=/root/.local/share/pnpm + export PATH="$PNPM_HOME:$PATH" + + pnpm install --frozen-lockfile + + # ── The actual smoke test ────────────────────────────── + # `dot init -y` runs the dependency installer end-to-end. + # If init reports "setup complete" with no failed rows, + # every tool was installed AND was reachable to the + # next step in the same process — which is the exact + # path config that #118 broke. + bun run src/index.ts init -y 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 any failed-row markers. Ink renders them with + # ✗ (U+2717) — match common shapes plus the literal word. + if grep -E "✗|✕| failed| error" /tmp/init.log; then + echo "::error::dot init reported a failed dependency" + exit 1 + fi + ' From 3e171941b108f9ea3ef8d9072cd796db605a1a4f Mon Sep 17 00:00:00 2001 From: ottovlotto <142217647+ottovlotto@users.noreply.github.com> Date: Mon, 4 May 2026 16:47:21 +0200 Subject: [PATCH 07/15] fix(e2e): adjust init test timeouts and bypass connect() in toolchain test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump init session-detection timeouts 15s → 25s. The connect()/QR flow can take >15s when the statement-store endpoint is slow, producing flaky 'expected QR prompt' failures even though the CLI behaviour is correct. - Pass `-y` to the toolchain-detection test (was reaching SIGTERM before the TUI rendered) so the dependency table renders within ~1s. The 12s execa timeout still terminates well before any real rustup install completes — exactly the window where we should see '· rustup' / '⠋ rustup', not '✓ rustup'. Verified locally: full offline-eligible subset (init, install, build, session) passes with E2E_ALLOW_OFFLINE_SETUP=1. --- e2e/cli/init.test.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/e2e/cli/init.test.ts b/e2e/cli/init.test.ts index 875932f3..b3ffe869 100644 --- a/e2e/cli/init.test.ts +++ b/e2e/cli/init.test.ts @@ -68,7 +68,7 @@ describe("dot init — session detection", () => { // in a fresh tempHome takes longer than 15s — nothing about sessions. const result = await dot(["init"], { home: tempHome, - timeout: 15_000, + timeout: 25_000, }); const output = result.stdout + result.stderr; expect( @@ -99,7 +99,7 @@ describe("dot init — session detection", () => { const result = await dot(["init"], { home: tempHome, - timeout: 15_000, + timeout: 25_000, }); const output = result.stdout + result.stderr; expect(result.exitCode).not.toBe(0); @@ -164,15 +164,18 @@ describe("dot init — toolchain detection", () => { // post-install path-config logic. This test forces init to hit // the "rustup not found" branch by removing it from PATH. // - // We use a 5s timeout — long enough for init's TUI to render the - // dependency table (and start a curl install attempt), short - // enough that we don't actually finish a real rustup install. - // We do NOT assert exitCode here; execa terminates the process at - // the timeout, so exitCode is the kill signal, not a real result. - const result = await dot(["init"], { + // `-y` skips the connect()/QR block so the TUI renders the + // dependency table within ~1s. The 12s execa timeout is long + // enough for the table to render and the rustup-install curl + // pipeline to *start*, but well short of the ~30s+ a real + // rustup install needs — we want to see "⠋ rustup" or "· + // rustup", NOT "✓ rustup". + // We do NOT assert exitCode here; execa terminates at the + // timeout, so exitCode is the kill signal, not a real result. + const result = await dot(["init", "-y"], { home: tempHome, env: { PATH: pathWithoutToolchains() }, - timeout: 5_000, + timeout: 12_000, }); const output = result.stdout + result.stderr; // The TUI prints each dependency on its own row. Seeing "rustup" From 1fda854a5a97732448e21730a5e75ad1fcc9017e Mon Sep 17 00:00:00 2001 From: ottovlotto <142217647+ottovlotto@users.noreply.github.com> Date: Mon, 4 May 2026 18:01:37 +0200 Subject: [PATCH 08/15] fix(e2e): drop bogus differential-CID check in re-deploy test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version mutated `dist/index.html` between the two deploys and asserted the second deploy produced a different metadata CID. That assumption was wrong: `buildMetadata()` in src/utils/deploy/playground.ts only puts `{repository, readme}` into the metadata JSON — the deployed content's CID is NOT included. Mutating dist therefore had no effect on the metadata CID, both deploys produced the same value, and the test failed in CI's pr-deploy-frontend cell. Worse, the regression I was guarding against ("second deploy silently no-ops") isn't actually a regression: a same-CID re-publish is correct behaviour — the registry already has what the user wants. Keep the meaningful checks: exit 0, "Deploy complete", and an independent registry query that verifies the on-chain entry contains the CID the CLI claimed it published. That alone rules out the only real failure mode (CLI prints success but never sent the extrinsic). --- e2e/cli/deploy.test.ts | 75 +++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 45 deletions(-) diff --git a/e2e/cli/deploy.test.ts b/e2e/cli/deploy.test.ts index 0d6dbb9d..e4fb9269 100644 --- a/e2e/cli/deploy.test.ts +++ b/e2e/cli/deploy.test.ts @@ -14,7 +14,6 @@ */ import { describe, test, expect } from "vitest"; -import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { dot } from "./helpers/dot.js"; import { SIGNER, BOB, E2E_DOMAINS } from "./fixtures/accounts.js"; @@ -316,50 +315,36 @@ describe("dot deploy --playground — full pipeline (requires Paseo + IPFS)", () const firstCid = extractMetadataCid(first.stdout); expect(firstCid, "first deploy did not print Metadata CID").not.toBeNull(); - // Mutate the build output between the two deploys so the second - // deploy must produce a DIFFERENT metadata CID. Without this, a - // regression where the second deploy silently no-ops (returns the - // previous result without re-publishing) would still print - // "Deploy complete" with the old CID and the test would pass. - const indexHtml = resolve(absBuildDir(frontendOnly), "index.html"); - const original = readFileSync(indexHtml, "utf8"); - writeFileSync( - indexHtml, - `${original}\n\n`, - ); - - try { - const second = await dot([ - "deploy", - "--signer", "dev", - "--domain", domain, - "--buildDir", absBuildDir(frontendOnly), - "--no-build", - "--playground", - "--suri", SIGNER.suri, - "--dir", frontendOnly, - ], { timeout: 400_000 }); - expect( - second.exitCode, - `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(); - // Different content → different metadata CID. If these match, - // the second deploy didn't actually re-publish. - expect( - secondCid, - `re-deploy produced same CID as first — content didn't change on chain`, - ).not.toBe(firstCid); - // And the registry should reflect the latest publish. - const entry = await getApp(`${domain}.dot`); - expect(entry).not.toBeNull(); - expect(entry!.metadataUri).toContain(secondCid!); - } finally { - // Restore so subsequent tests see the original fixture content. - writeFileSync(indexHtml, original); - } + const second = await dot([ + "deploy", + "--signer", "dev", + "--domain", domain, + "--buildDir", absBuildDir(frontendOnly), + "--no-build", + "--playground", + "--suri", SIGNER.suri, + "--dir", frontendOnly, + ], { timeout: 400_000 }); + expect( + second.exitCode, + `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 () => { From dce0336e86fdbe19cac1fdd6997980de72d209d8 Mon Sep 17 00:00:00 2001 From: ottovlotto <142217647+ottovlotto@users.noreply.github.com> Date: Mon, 4 May 2026 18:25:21 +0200 Subject: [PATCH 09/15] test(e2e): strengthen mod happy-path test to cover the full clone flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version of this test verified `exitCode === 0` and that a slug-prefix directory existed with a `package.json`. That assertion would pass even if `dot mod` invented an arbitrary `package.json` from thin air and silently skipped both setup.sh and the side effects of the source-download step. `dot mod`'s flow has three steps (see src/commands/mod/SetupScreen.tsx): 1. fetch app metadata 2. download source — extract tarball + writeDotJson + ignoreModLogs + stripPostinstall + createOptionalGitBaseline 3. run setup.sh (or warn if absent) Steps 2's side effects and step 3 weren't observed at all. Strengthen the test to assert on: - multiple specific files from the upstream paritytech/Rock-Paper- Scissors fixture (README.md, setup.sh, src/, vite.config.ts) — proves the GitHub tarball was actually fetched and extracted, not invented. - dot.json was written with the expected `domain` (relative slug-suffix path, per writeDotJson()) and `name` fields. - .gitignore contains the mod-log entries appended by ignoreModLogs(). - .dot-mod-setup.log exists with `[setup]`-prefixed content from the fixture's setup.sh — proves step 3 ran, not just that the file was copied across. Bumped the per-test timeout 180s → 240s to give setup.sh's `npm install` enough headroom on a cold cache. Same fixture, no new registration needed. --- e2e/cli/mod.test.ts | 65 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/e2e/cli/mod.test.ts b/e2e/cli/mod.test.ts index 5dcb645e..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]"); }, ); From dbe565518139a1c4046779b84b25238ba16174a1 Mon Sep 17 00:00:00 2001 From: ottovlotto <142217647+ottovlotto@users.noreply.github.com> Date: Tue, 5 May 2026 14:39:31 +0200 Subject: [PATCH 10/15] test(e2e): pass --private on all fixture deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hides the e2e-cli-* fixture domains from the public playground registry grid (visibility=0). Tests still publish and verify exactly as before; SIGNER still sees them under My Apps. Cleans up new test traffic only — the existing 9 visible entries need a one-off setVisibility flip. --- e2e/cli/chaos.test.ts | 1 + e2e/cli/deploy.test.ts | 15 +++++++++++++++ e2e/cli/diagnostic.test.ts | 2 ++ 3 files changed, 18 insertions(+) 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 e4fb9269..9e0406ff 100644 --- a/e2e/cli/deploy.test.ts +++ b/e2e/cli/deploy.test.ts @@ -68,6 +68,7 @@ function runContractDeployTest(cfg: ContractDeployTestConfig): void { "--contracts", "--no-contract-build", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", cfg.fixture, ], { timeout: 400_000 }); @@ -102,6 +103,7 @@ describe("dot deploy — preflight and validation", () => { "--domain", E2E_DOMAINS.preflight, "--buildDir", absBuildDir(frontendOnly), "--playground", + "--private", "--env", "mainnet", "--suri", SIGNER.suri, "--dir", frontendOnly, @@ -127,6 +129,7 @@ describe("dot deploy — preflight and validation", () => { "--no-build", "--contracts", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", foundry, ]); @@ -149,6 +152,7 @@ describe("dot deploy — preflight and validation", () => { "--no-build", "--contracts", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", hardhat, ]); @@ -169,6 +173,7 @@ describe("dot deploy — preflight and validation", () => { "--no-build", "--contracts", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", rustCdm, ]); @@ -189,6 +194,7 @@ describe("dot deploy — preflight and validation", () => { "--no-build", "--contracts", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", multiContract, ]); @@ -209,6 +215,7 @@ describe("dot deploy — preflight and validation", () => { "--no-build", "--contracts", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", frontendOnly, ], { timeout: 400_000 }); @@ -228,6 +235,7 @@ describe("dot deploy — preflight and validation", () => { "--domain", domain, "--buildDir", absBuildDir(frontendOnly), "--playground", + "--private", "--suri", SIGNER.suri, "--dir", frontendOnly, ], { timeout: 400_000 }); @@ -273,6 +281,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 }); @@ -307,6 +316,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 }); @@ -322,6 +332,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 }); @@ -355,6 +366,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 }); @@ -369,6 +381,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 }); @@ -401,6 +414,7 @@ describe("dot deploy — rejects --no-contract-build with no artefacts", () => { "--contracts", "--no-contract-build", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", constructorArgs, ]); @@ -432,6 +446,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 5baf2db5..c9f05e64 100644 --- a/e2e/cli/diagnostic.test.ts +++ b/e2e/cli/diagnostic.test.ts @@ -30,6 +30,7 @@ describe("diagnostic mode", () => { "--buildDir", resolve(frontendOnly, "dist"), "--no-build", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", frontendOnly, ], { @@ -65,6 +66,7 @@ describe("diagnostic mode", () => { "--buildDir", resolve(frontendOnly, "dist"), "--no-build", "--playground", + "--private", "--suri", SIGNER.suri, "--dir", frontendOnly, ], { From 95b58115ba9addccf44162fe71bd68bf6af6b32f Mon Sep 17 00:00:00 2001 From: ottovlotto <142217647+ottovlotto@users.noreply.github.com> Date: Tue, 5 May 2026 16:59:50 +0200 Subject: [PATCH 11/15] test(e2e,ci): apply review fixes from main-merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - init-cold-smoke: switch runs-on to the parity-default conditional expression added in #120 (was the only ubuntu-latest literal left in the file). Tighten the failed-row grep: the previous " error" / " failed" substring match would also trip on benign output like "0 errors" and apt warnings; now anchor on "✕ "/"✗ " rendered by the dot TUI plus the literal "failed dependency" marker. Add a comment confirming `bun run src/index.ts` is intentional — it matches the entry path used by the e2e/cli/ vitest suite (per CLAUDE.md), and the published-binary install is covered separately by e2e-post-release.yml. - deploy.test.ts ordering check: the previous regex /\n>\s+\w/ also matches build-tool stdout that the CLI pipes through (pnpm prints `> @scope/pkg@1 build /path` for every run-script, vite logs `> built in 234ms`, etc.). Anchor on the dot CLI's exact banner format `\n> (pnpm|npm|yarn|bun|npx) ` from src/commands/build.ts:15 + src/utils/build/detect.ts so unrelated `> ` lines can't satisfy it. - init.test.ts toolchain detection: drop the timing-based 12s window in favour of forcing the rustup install to fail deterministically via HTTPS_PROXY=http://127.0.0.1:1 (curl's connection-refused is ~instant). The test's contract is that init should not falsely conclude rustup is installed when PATH is stripped — that no longer depends on whether a cache makes the real install fast. Cold-start install success is covered separately by the smoke job. --- .github/workflows/e2e.yml | 20 ++++++++++++++---- e2e/cli/deploy.test.ts | 15 +++++++------- e2e/cli/init.test.ts | 43 +++++++++++++++++++++++++++------------ 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f3df69c9..a0363403 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -482,7 +482,7 @@ jobs: # (rustup, nightly, cdm, foundry, kubo) from the network and takes # ~10 min. Daily cron + manual dispatch only. name: Init cold-start smoke test - runs-on: ubuntu-latest + runs-on: ${{ github.repository_owner == 'paritytech' && 'parity-default' || 'ubuntu-latest' }} timeout-minutes: 30 if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' steps: @@ -517,6 +517,14 @@ jobs: # ── The actual smoke test ────────────────────────────── # `dot init -y` runs the dependency installer end-to-end. + # We run it via `bun run src/index.ts` rather than the + # SEA binary because that is the same entry path used by + # the e2e/cli/ vitest suite (see CLAUDE.md → E2E Tests), + # which keeps the install-path code under test consistent + # across local / per-PR / cold-start runs. The published- + # binary install path is covered separately by + # e2e-post-release.yml. + # # If init reports "setup complete" with no failed rows, # every tool was installed AND was reachable to the # next step in the same process — which is the exact @@ -528,9 +536,13 @@ jobs: exit 1 } - # Reject any failed-row markers. Ink renders them with - # ✗ (U+2717) — match common shapes plus the literal word. - if grep -E "✗|✕| failed| error" /tmp/init.log; then + # 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/deploy.test.ts b/e2e/cli/deploy.test.ts index 9e0406ff..a72f12b9 100644 --- a/e2e/cli/deploy.test.ts +++ b/e2e/cli/deploy.test.ts @@ -249,13 +249,14 @@ describe("dot deploy — preflight and validation", () => { `availability banner not found:\n${output}`, ).toBeGreaterThan(-1); // Verify the *ordering* claim in the test name: availability must - // precede any build-runner output. Without this, the test only proves - // availability ran, not that it ran first. Build runners emit the - // header `> ` (see src/commands/build.ts) and - // bulletin-deploy's storage phase emits `▸ storage-and-dotns…` - // (logHeadlessEvent). Either appearing before availability would - // break the contract. - const buildIdx = output.search(/\n>\s+\w/); + // 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( diff --git a/e2e/cli/init.test.ts b/e2e/cli/init.test.ts index b3ffe869..dbca6795 100644 --- a/e2e/cli/init.test.ts +++ b/e2e/cli/init.test.ts @@ -164,18 +164,32 @@ describe("dot init — toolchain detection", () => { // post-install path-config logic. This test forces init to hit // the "rustup not found" branch by removing it from PATH. // + // Why HTTPS_PROXY is set to a refused address: + // init's rustup install step pipes `curl https://sh.rustup.rs` + // into `sh` (see src/utils/toolchain.ts). On a runner whose + // outbound network is fast or where rustup-init is cached, the + // install can complete in <12s and the test would race against a + // timing-based "not yet ✓" assertion. Pointing the proxy at a + // guaranteed-refused port forces curl to fail in <1s, which + // makes the install step deterministically fail (✕ rustup) and + // removes the timing dependency entirely. We're testing the + // DETECTION path, not the install-success path — the cold-start + // smoke job in .github/workflows/e2e.yml covers install success. + // // `-y` skips the connect()/QR block so the TUI renders the - // dependency table within ~1s. The 12s execa timeout is long - // enough for the table to render and the rustup-install curl - // pipeline to *start*, but well short of the ~30s+ a real - // rustup install needs — we want to see "⠋ rustup" or "· - // rustup", NOT "✓ rustup". - // We do NOT assert exitCode here; execa terminates at the - // timeout, so exitCode is the kill signal, not a real result. + // dependency table within ~1s. We do NOT assert exitCode: with + // the install forced to fail, init may still exit 0 (warnings + // are non-fatal) or non-zero, depending on which downstream + // steps the path-stripping affects. Either is consistent with + // the contract we care about here. const result = await dot(["init", "-y"], { home: tempHome, - env: { PATH: pathWithoutToolchains() }, - timeout: 12_000, + env: { + PATH: pathWithoutToolchains(), + HTTPS_PROXY: "http://127.0.0.1:1", + https_proxy: "http://127.0.0.1:1", + }, + timeout: 20_000, }); const output = result.stdout + result.stderr; // The TUI prints each dependency on its own row. Seeing "rustup" @@ -189,10 +203,13 @@ describe("dot init — toolchain detection", () => { output, `expected later toolchain rows in dependency table\n${output}`, ).toMatch(/Rust nightly|cdm|foundry|IPFS/); - // A "✓ rustup" with this PATH would mean init falsely concluded - // rustup is installed — exactly the class of bug that lets fresh - // users hit broken installs in production. (Fresh installs render - // the row as "· rustup" or "⠋ rustup", not "✓".) + // A "✓ rustup" here would mean init falsely concluded rustup is + // installed despite the stripped PATH — exactly the class of bug + // that lets fresh users hit broken installs in production. With + // the network-blocked install above, a healthy run renders the + // row as "· rustup", "⠋ rustup", or "✕ rustup" (install failed), + // none of which match this regex. (✓ glyph from + // src/utils/ui/theme/tokens.ts.) expect( output, `init reported rustup as installed despite stripped PATH:\n${output}`, From 2eaa7423f6a10285cabc88c6f0dfc69969e0ee14 Mon Sep 17 00:00:00 2001 From: ottovlotto <142217647+ottovlotto@users.noreply.github.com> Date: Tue, 5 May 2026 17:08:38 +0200 Subject: [PATCH 12/15] fix(e2e): strip actual rustup/cargo/forge dirs in toolchain test, not just patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version stripped any PATH segment matching /cargo/, /rustup/, or /foundry/. That works on standard GitHub runners (rustup at ~/.cargo/bin) but fails on parity-default, where the rustup binary lives at a path that doesn't contain any of those tokens (e.g. /usr/local/bin). Result: commandExists("rustup") in init returns true even with PATH "stripped", and the test asserts ✕ rustup but observes ✓ rustup. Resolve each toolchain binary's actual location(s) via `which -a` up front and strip exactly those parent dirs. Keep the original pattern-strip as a belt-and-braces fallback for related dirs that don't contain a binary directly (e.g. an empty ~/.cargo/bin on a clean home). --- e2e/cli/init.test.ts | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/e2e/cli/init.test.ts b/e2e/cli/init.test.ts index dbca6795..50a3b00b 100644 --- a/e2e/cli/init.test.ts +++ b/e2e/cli/init.test.ts @@ -19,22 +19,47 @@ */ import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { execSync } from "node:child_process"; import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { dot } from "./helpers/dot.js"; -/** PATH stripped of rustup/cargo/foundry locations. The init process must - * still be able to find `node`/`bun`/`pnpm`/`curl`/`bash`, so we keep the - * rest of PATH intact. Patterns are deliberately broad (cargo/rustup/ - * foundry anywhere in the segment) so we strip both `~/.cargo/bin` style - * paths and `/opt/cargo/bin` / `/usr/local/cargo/bin` style ones. */ +/** PATH stripped of every directory that currently contains a rust / + * foundry toolchain binary. The init process must still be able to find + * `node`/`bun`/`pnpm`/`curl`/`bash`, so we keep the rest of PATH intact. + * + * Naive pattern-based filtering (`/cargo/`, `/rustup/`, `/foundry/`) is + * not enough on every CI runner: parity-default ships rustup at a path + * that doesn't include any of those tokens (e.g. `/usr/local/bin`), so a + * pattern strip leaves rustup discoverable and the test fires a false + * ✓ rustup. Resolve each tool's actual location(s) up front and strip + * exactly those parent dirs. Pattern strip is kept as a belt-and-braces + * fallback for tools we don't enumerate. */ function pathWithoutToolchains(): string { const original = process.env.PATH ?? "/usr/bin:/bin"; + const stripDirs = new Set(); + const tools = ["rustup", "cargo", "rustc", "forge", "foundryup", "foundryup-polkadot"]; + for (const tool of tools) { + try { + const out = execSync(`which -a ${tool} 2>/dev/null || true`, { + encoding: "utf8", + }); + for (const line of out.split("\n")) { + const path = line.trim(); + if (path) stripDirs.add(dirname(path)); + } + } catch { /* tool not installed — nothing to strip */ } + } const stripPatterns = [/cargo/i, /rustup/i, /foundry/i]; return original .split(":") - .filter((p) => p.length > 0 && !stripPatterns.some((re) => re.test(p))) + .filter( + (p) => + p.length > 0 && + !stripDirs.has(p) && + !stripPatterns.some((re) => re.test(p)), + ) .join(":"); } From 3e7a3f8a7ad5eb1b4c4f5dabfe819e77b2a30eb1 Mon Sep 17 00:00:00 2001 From: ottovlotto <142217647+ottovlotto@users.noreply.github.com> Date: Tue, 5 May 2026 17:20:50 +0200 Subject: [PATCH 13/15] test(e2e): drop flaky toolchain-detection test, defer to cold-smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "detects rustup as missing when not on PATH" test failed in CI in a way that no PATH-strip approach can fix on this runner. Pattern: with PATH stripped of every directory we can resolve, `command -v rustup` inside init STILL returned 0 — but actually executing `rustup ...` returned 127 (command not found). That signature is consistent with parity-default having a rustup wrapper script in /usr/bin (or similar shared system path) that delegates into $HOME/.cargo/bin/rustup. We set HOME to a clean tempHome so the delegation target doesn't exist — the wrapper is found but exec fails. We can't strip /usr/bin without breaking sibling binaries init legitimately needs. The cold-start smoke job in .github/workflows/e2e.yml runs `dot init` in a fresh ubuntu:22.04 with no toolchain pre-installed. That's the correct environment to assert detection + install-then-use against, and it's already wired up (daily cron + workflow_dispatch). Drop the per-PR test and update the file's KNOWN GAP comment to point readers to the smoke job. --- e2e/cli/init.test.ts | 135 +++---------------------------------------- 1 file changed, 9 insertions(+), 126 deletions(-) diff --git a/e2e/cli/init.test.ts b/e2e/cli/init.test.ts index 50a3b00b..566690da 100644 --- a/e2e/cli/init.test.ts +++ b/e2e/cli/init.test.ts @@ -8,61 +8,22 @@ * - Dev signer (--suri) bypasses session requirement * * KNOWN GAP — toolchain install paths (rustup, IPFS, foundry, cdm) are NOT - * exercised in CI because the runner image already has those tools on PATH - * before tests run. As a result, regressions in the install / post-install - * path-config logic (e.g. paritytech/playground-app#118 — newly-installed - * rustup not reachable from the same init process) will pass these tests - * silently. To catch that class of bug, init has to be exercised in a fresh - * sandbox (Docker / VM with no Rust toolchain). The test below at least - * pins the toolchain *detection* output so the dependency table can't be - * silently dropped, but it does NOT validate the install-then-use flow. + * 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) runs `dot init` in a + * fresh ubuntu:22.04 container with no toolchain pre-installed and is the + * authoritative test for detection + install-then-use. It runs daily + + * workflow_dispatch. */ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { execSync } from "node:child_process"; import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { tmpdir } from "node:os"; import { dot } from "./helpers/dot.js"; -/** PATH stripped of every directory that currently contains a rust / - * foundry toolchain binary. The init process must still be able to find - * `node`/`bun`/`pnpm`/`curl`/`bash`, so we keep the rest of PATH intact. - * - * Naive pattern-based filtering (`/cargo/`, `/rustup/`, `/foundry/`) is - * not enough on every CI runner: parity-default ships rustup at a path - * that doesn't include any of those tokens (e.g. `/usr/local/bin`), so a - * pattern strip leaves rustup discoverable and the test fires a false - * ✓ rustup. Resolve each tool's actual location(s) up front and strip - * exactly those parent dirs. Pattern strip is kept as a belt-and-braces - * fallback for tools we don't enumerate. */ -function pathWithoutToolchains(): string { - const original = process.env.PATH ?? "/usr/bin:/bin"; - const stripDirs = new Set(); - const tools = ["rustup", "cargo", "rustc", "forge", "foundryup", "foundryup-polkadot"]; - for (const tool of tools) { - try { - const out = execSync(`which -a ${tool} 2>/dev/null || true`, { - encoding: "utf8", - }); - for (const line of out.split("\n")) { - const path = line.trim(); - if (path) stripDirs.add(dirname(path)); - } - } catch { /* tool not installed — nothing to strip */ } - } - const stripPatterns = [/cargo/i, /rustup/i, /foundry/i]; - return original - .split(":") - .filter( - (p) => - p.length > 0 && - !stripDirs.has(p) && - !stripPatterns.some((re) => re.test(p)), - ) - .join(":"); -} - function makeTempHome(): string { const dir = mkdtempSync(join(tmpdir(), "dot-e2e-init-")); mkdirSync(join(dir, ".polkadot-apps"), { recursive: true }); @@ -164,81 +125,3 @@ describe("dot init — dev signer bypass", () => { }); }); -describe("dot init — toolchain detection", () => { - let tempHome: string; - - beforeEach(() => { - tempHome = makeTempHome(); - }); - - afterEach(() => { - try { - rmSync(tempHome, { recursive: true, force: true }); - } catch { /* best-effort */ } - }); - - test( - "detects rustup as missing when not on PATH", - { timeout: 30_000 }, - async () => { - // Strip rustup/cargo from PATH and verify init reports rustup as - // a missing dependency rather than skipping straight to "✓ rustup". - // - // Why this matters: CI runners pre-install rustup, so the regular - // init tests never exercise the missing-tool detection or the - // post-install path-config logic. This test forces init to hit - // the "rustup not found" branch by removing it from PATH. - // - // Why HTTPS_PROXY is set to a refused address: - // init's rustup install step pipes `curl https://sh.rustup.rs` - // into `sh` (see src/utils/toolchain.ts). On a runner whose - // outbound network is fast or where rustup-init is cached, the - // install can complete in <12s and the test would race against a - // timing-based "not yet ✓" assertion. Pointing the proxy at a - // guaranteed-refused port forces curl to fail in <1s, which - // makes the install step deterministically fail (✕ rustup) and - // removes the timing dependency entirely. We're testing the - // DETECTION path, not the install-success path — the cold-start - // smoke job in .github/workflows/e2e.yml covers install success. - // - // `-y` skips the connect()/QR block so the TUI renders the - // dependency table within ~1s. We do NOT assert exitCode: with - // the install forced to fail, init may still exit 0 (warnings - // are non-fatal) or non-zero, depending on which downstream - // steps the path-stripping affects. Either is consistent with - // the contract we care about here. - const result = await dot(["init", "-y"], { - home: tempHome, - env: { - PATH: pathWithoutToolchains(), - HTTPS_PROXY: "http://127.0.0.1:1", - https_proxy: "http://127.0.0.1:1", - }, - timeout: 20_000, - }); - const output = result.stdout + result.stderr; - // The TUI prints each dependency on its own row. Seeing "rustup" - // proves the detection table rendered. Pair with one of the later - // rows so a single-line corruption can't pass. - expect( - output, - `expected dependency table to render\n${output}`, - ).toContain("rustup"); - expect( - output, - `expected later toolchain rows in dependency table\n${output}`, - ).toMatch(/Rust nightly|cdm|foundry|IPFS/); - // A "✓ rustup" here would mean init falsely concluded rustup is - // installed despite the stripped PATH — exactly the class of bug - // that lets fresh users hit broken installs in production. With - // the network-blocked install above, a healthy run renders the - // row as "· rustup", "⠋ rustup", or "✕ rustup" (install failed), - // none of which match this regex. (✓ glyph from - // src/utils/ui/theme/tokens.ts.) - expect( - output, - `init reported rustup as installed despite stripped PATH:\n${output}`, - ).not.toMatch(/✓\s+rustup\b/); - }, - ); -}); From a41c0469ba8bde7541f440791599a306b82543ba Mon Sep 17 00:00:00 2001 From: ottovlotto <142217647+ottovlotto@users.noreply.github.com> Date: Tue, 5 May 2026 19:06:34 +0200 Subject: [PATCH 14/15] ci(e2e): cold-smoke installs via install.sh after Dev Release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bun + pnpm + `bun run src/index.ts` setup with the same `curl … install.sh | VERSION=dev/ bash` one-liner the dev-release bot posts on each PR, so the smoke exercises the SEA binary end users actually install (catches Bun-compile-only regressions that source-mode masked) and triggers via workflow_run after Dev Release succeeds. Schedule + workflow_dispatch keep working against the latest stable tag. --- .github/workflows/e2e.yml | 82 ++++++++++++++++++++------------------- e2e/cli/init.test.ts | 9 +++-- 2 files changed, 48 insertions(+), 43 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a0363403..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 @@ -478,58 +484,56 @@ jobs: # dependency must be installed AND become reachable to subsequent # init steps in the same process. # - # Excluded from per-PR runs because it pulls full toolchains - # (rustup, nightly, cdm, foundry, kubo) from the network and takes - # ~10 min. Daily cron + manual dispatch only. + # 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' + if: | + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') steps: - - uses: actions/checkout@v4 - - - name: Run dot init in fresh ubuntu container + - 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 \ - -v "${{ github.workspace }}:/work" \ - -w /work \ -e CI=true \ + -e VERSION="$VERSION_OVERRIDE" \ ubuntu:22.04 \ bash -eux -c ' - # Minimal prerequisites — anything beyond these is what - # `dot init` is expected to install for itself. + # 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 unzip git build-essential sudo - - # bun (CLI runtime) - export BUN_INSTALL=/usr/local/bun - curl -fsSL https://bun.sh/install | bash - export PATH="$BUN_INSTALL/bin:$PATH" - - # pnpm (package manager) — install via standalone script - # so we do not depend on a pre-existing Node. - curl -fsSL https://get.pnpm.io/install.sh | env SHELL=$(which bash) bash - - export PNPM_HOME=/root/.local/share/pnpm - export PATH="$PNPM_HOME:$PATH" - - pnpm install --frozen-lockfile + curl ca-certificates bash # ── The actual smoke test ────────────────────────────── - # `dot init -y` runs the dependency installer end-to-end. - # We run it via `bun run src/index.ts` rather than the - # SEA binary because that is the same entry path used by - # the e2e/cli/ vitest suite (see CLAUDE.md → E2E Tests), - # which keeps the install-path code under test consistent - # across local / per-PR / cold-start runs. The published- - # binary install path is covered separately by - # e2e-post-release.yml. - # - # If init reports "setup complete" with no failed rows, - # every tool was installed AND was reachable to the - # next step in the same process — which is the exact - # path config that #118 broke. - bun run src/index.ts init -y 2>&1 | tee /tmp/init.log + # 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'\''" diff --git a/e2e/cli/init.test.ts b/e2e/cli/init.test.ts index 566690da..ec184d8a 100644 --- a/e2e/cli/init.test.ts +++ b/e2e/cli/init.test.ts @@ -12,10 +12,11 @@ * 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) runs `dot init` in a - * fresh ubuntu:22.04 container with no toolchain pre-installed and is the - * authoritative test for detection + install-then-use. It runs daily + - * workflow_dispatch. + * 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"; From afd6f91b7b8f67d40f2d534ff582699b02cbea82 Mon Sep 17 00:00:00 2001 From: ottovlotto <142217647+ottovlotto@users.noreply.github.com> Date: Tue, 5 May 2026 19:22:14 +0200 Subject: [PATCH 15/15] test(e2e): self-heal mod fixture across registry redeploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureTemplateRegistered() previously only verified the fixture exists and threw if missing — leaving the mod test red whenever the registry contract was redeployed. The mod test is read-only, so unlike the deploy fixtures (which re-publish on every run via the deploy tests themselves), the mod fixture had no self-healing path. Now: register-if-missing using the same SIGNER fundDeployerIfLow has already topped up. Same domain, same owner, idempotent re-publish; no fresh DotNS entry is ever burned, so the registry stays clean. --- e2e/cli/fixtures/registry.ts | 48 +++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 6 deletions(-) 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(); + } }