diff --git a/scripts/contributor-bootstrap.test.js b/scripts/contributor-bootstrap.test.js index adbddd1c..fa3c1101 100644 --- a/scripts/contributor-bootstrap.test.js +++ b/scripts/contributor-bootstrap.test.js @@ -1,110 +1,276 @@ -const fs = require("fs"); -const path = require("path"); -const { runBootstrap, DEV_PROFILES, copyEnvExampleIfMissing, getProfileDependencies } = require("./contributor-bootstrap"); - -// Mock fs and child_process -jest.mock("fs"); -jest.mock("node:child_process", () => ({ - spawnSync: jest.fn(() => ({ - error: null, - status: 0, - stdout: "v20.10.0", - stderr: "", - })), -})); - -describe("contributor-bootstrap", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); +/** + * QA-206: Contributor bootstrap tests — clean checkout validation + failure paths. + * + * Covers: + * - trackNextSteps for all valid track identifiers (be, fe, sc, sdk, default) + * - checkCommand success and failure paths with remediation messages + * - runBootstrap structured result shape and failures propagation + * - Clean-checkout assertions: required lockfiles and config files exist + * - Root package.json exposes the bootstrap and bootstrap:json scripts + * - JSON serialisability of bootstrap output (for --json CI mode) + * + * Run: node --test scripts/contributor-bootstrap.test.js + */ - describe("DEV_PROFILES", () => { - it("defines all required profiles", () => { - const expectedProfiles = ["frontend-only", "indexer-only", "soroban-only", "full-stack"]; - expect(Object.keys(DEV_PROFILES)).toEqual(expect.arrayContaining(expectedProfiles)); - }); - - it("each profile has required configuration", () => { - for (const [profileName, config] of Object.entries(DEV_PROFILES)) { - expect(config.description).toBeDefined(); - expect(config.dependencies).toBeInstanceOf(Array); - expect(config.requiredEnvVars).toBeInstanceOf(Array); - expect(config.installCommand).toBeDefined(); - expect(config.nextSteps).toBeInstanceOf(Array); - } - }); - }); +"use strict"; - describe("runBootstrap", () => { - it("returns error for unknown profile", () => { - const result = runBootstrap("invalid-profile"); - expect(result.ok).toBe(false); - expect(result.error).toContain("Unknown profile"); - expect(result.availableProfiles).toEqual(Object.keys(DEV_PROFILES)); - }); - - it("successfully runs bootstrap for frontend-only profile", () => { - const result = runBootstrap("frontend-only"); - expect(result.ok).toBe(true); - expect(result.profile).toBe("frontend-only"); - expect(result.description).toBe("Develop only the frontend application"); - expect(result.checks.length).toBe(DEV_PROFILES["frontend-only"].dependencies.length); - }); - - it("successfully runs bootstrap for indexer-only profile", () => { - const result = runBootstrap("indexer-only"); - expect(result.ok).toBe(true); - expect(result.profile).toBe("indexer-only"); - expect(result.checks.length).toBe(DEV_PROFILES["indexer-only"].dependencies.length); - }); - - it("successfully runs bootstrap for soroban-only profile", () => { - const result = runBootstrap("soroban-only"); - expect(result.ok).toBe(true); - expect(result.profile).toBe("soroban-only"); - expect(result.checks.length).toBe(DEV_PROFILES["soroban-only"].dependencies.length); - }); - - it("successfully runs bootstrap for full-stack profile (default)", () => { - const result = runBootstrap(); - expect(result.ok).toBe(true); - expect(result.profile).toBe("full-stack"); - expect(result.checks.length).toBe(DEV_PROFILES["full-stack"].dependencies.length); - }); - }); +const assert = require("node:assert/strict"); +const test = require("node:test"); +const fs = require("node:fs"); +const path = require("node:path"); +const { checkCommand, runBootstrap, trackNextSteps } = require("./contributor-bootstrap.js"); - describe("copyEnvExampleIfMissing", () => { - it("copies env example when target doesn't exist", () => { - fs.existsSync.mockImplementation((path) => path.endsWith(".env.example")); - fs.copyFileSync.mockImplementation(() => {}); - - const result = copyEnvExampleIfMissing("/test/.env.example", "/test/.env.local"); - expect(result.copied).toBe(true); - expect(fs.copyFileSync).toHaveBeenCalledWith("/test/.env.example", "/test/.env.local"); - }); - - it("doesn't copy when target already exists", () => { - fs.existsSync.mockReturnValue(true); - fs.copyFileSync.mockImplementation(() => {}); - - const result = copyEnvExampleIfMissing("/test/.env.example", "/test/.env.local"); - expect(result.copied).toBe(false); - expect(result.exists).toBe(true); - expect(fs.copyFileSync).not.toHaveBeenCalled(); - }); - }); +const REPO_ROOT = path.resolve(__dirname, ".."); + +// --------------------------------------------------------------------------- +// trackNextSteps — all track variants +// --------------------------------------------------------------------------- + +test("trackNextSteps: 'be' returns backend-specific guidance", () => { + assert.deepEqual(trackNextSteps("be"), [ + "Run `npm run lint --prefix backend`", + "Run `npm run typecheck --prefix backend`", + "Run `npm run test --prefix backend -- --runInBand`", + ]); +}); + +test("trackNextSteps: 'fe' returns frontend-specific guidance", () => { + assert.deepEqual(trackNextSteps("fe"), [ + "Run `npm run lint --prefix frontend`", + "Run `npm run typecheck --prefix frontend`", + ]); +}); + +test("trackNextSteps: 'sc' returns soroban/cargo guidance", () => { + assert.deepEqual(trackNextSteps("sc"), [ + "Run `cargo check --workspace`", + "Run `cargo test --workspace`", + ]); +}); + +test("trackNextSteps: 'sdk' returns TS SDK typecheck guidance", () => { + assert.deepEqual(trackNextSteps("sdk"), [ + "Run `npm run typecheck --prefix soroban/sdk/ts`", + ]); +}); + +test("trackNextSteps: unknown track falls back to full CI guidance", () => { + const steps = trackNextSteps("unknown-track"); + assert.equal(Array.isArray(steps), true); + assert.ok(steps.length > 0, "default track must return at least one step"); + assert.ok( + steps[0].includes("ci-local.sh"), + `default guidance should reference ci-local.sh, got: ${steps[0]}`, + ); +}); + +test("trackNextSteps: 'all' falls back to full CI guidance", () => { + const steps = trackNextSteps("all"); + assert.equal(Array.isArray(steps), true); + assert.ok(steps.length > 0, "all track must return at least one step"); +}); + +// --------------------------------------------------------------------------- +// checkCommand — success path +// --------------------------------------------------------------------------- + +test("checkCommand: 'node' succeeds and returns ok:true with version output", () => { + const result = checkCommand("node"); + assert.equal(result.command, "node"); + assert.equal(result.ok, true); + assert.ok( + result.output.startsWith("v"), + `expected version string starting with 'v', got: ${result.output}`, + ); + assert.equal(result.remediation, undefined, "no remediation expected on success"); +}); + +test("checkCommand: 'npm' succeeds and returns ok:true", () => { + const result = checkCommand("npm"); + assert.equal(result.ok, true); + assert.equal(result.command, "npm"); + assert.equal(result.remediation, undefined); +}); + +// --------------------------------------------------------------------------- +// checkCommand — failure path (non-existent binary) +// --------------------------------------------------------------------------- + +test("checkCommand: non-existent binary returns ok:false with remediation", () => { + const result = checkCommand("__nonexistent_tool_xyz__"); + assert.equal(result.ok, false); + assert.equal(typeof result.remediation, "string"); + assert.ok(result.remediation.length > 0, "remediation message must not be empty"); + assert.ok( + result.remediation.includes("__nonexistent_tool_xyz__"), + `remediation should mention the missing tool, got: ${result.remediation}`, + ); +}); + +test("checkCommand: failure result includes command name", () => { + const result = checkCommand("__no_such_binary__"); + assert.equal(result.command, "__no_such_binary__"); + assert.equal(result.ok, false); +}); + +// --------------------------------------------------------------------------- +// runBootstrap — structured result shape +// --------------------------------------------------------------------------- - describe("getProfileDependencies", () => { - it("returns correct dependencies for frontend-only", () => { - const checks = getProfileDependencies("frontend-only"); - expect(checks.length).toBe(2); // node, npm - expect(checks.map(c => c.command)).toEqual(["node", "npm"]); - }); - - it("returns correct dependencies for full-stack", () => { - const checks = getProfileDependencies("full-stack"); - expect(checks.length).toBe(6); // node, npm, rustc, cargo, docker, psql - expect(checks.map(c => c.command)).toEqual(["node", "npm", "rustc", "cargo", "docker", "psql"]); - }); +test("runBootstrap returns a structured result with all required fields", () => { + const result = runBootstrap("all"); + assert.equal(typeof result.ok, "boolean"); + assert.equal(result.track, "all"); + assert.equal(Array.isArray(result.checks), true); + assert.equal(Array.isArray(result.nextSteps), true); + assert.equal(Array.isArray(result.failures), true); +}); + +test("runBootstrap: checks array contains entries for node, npm, rustc, cargo", () => { + const result = runBootstrap("all"); + const commandNames = result.checks.map((c) => c.command); + assert.ok(commandNames.includes("node"), "checks must include 'node'"); + assert.ok(commandNames.includes("npm"), "checks must include 'npm'"); + assert.ok(commandNames.includes("rustc"), "checks must include 'rustc'"); + assert.ok(commandNames.includes("cargo"), "checks must include 'cargo'"); +}); + +test("runBootstrap: node and npm are always available in the test environment", () => { + const result = runBootstrap("all"); + const nodeCheck = result.checks.find((c) => c.command === "node"); + const npmCheck = result.checks.find((c) => c.command === "npm"); + assert.equal(nodeCheck?.ok, true, "node must be available"); + assert.equal(npmCheck?.ok, true, "npm must be available"); +}); + +test("runBootstrap: ok is false when failures is non-empty, true otherwise", () => { + const result = runBootstrap("all"); + if (result.failures.length > 0) { + assert.equal(result.ok, false); + } else { + assert.equal(result.ok, true); + } +}); + +test("runBootstrap: every entry in failures corresponds to a failed check", () => { + const result = runBootstrap("all"); + for (const failedCmd of result.failures) { + const check = result.checks.find((c) => c.command === failedCmd); + assert.ok(check, `failures references '${failedCmd}' but it is not in checks`); + assert.equal( + check.ok, + false, + `'${failedCmd}' is in failures but its check.ok is true`, + ); + } +}); + +test("runBootstrap: 'be' track returns backend-oriented next steps", () => { + const result = runBootstrap("be"); + assert.equal(result.track, "be"); + assert.ok( + result.nextSteps.some((s) => s.includes("backend")), + "be track next steps must mention backend", + ); +}); + +test("runBootstrap: 'fe' track returns frontend-oriented next steps", () => { + const result = runBootstrap("fe"); + assert.equal(result.track, "fe"); + assert.ok( + result.nextSteps.some((s) => s.includes("frontend")), + "fe track next steps must mention frontend", + ); +}); + +test("runBootstrap: result is JSON-serialisable (for --json CI output mode)", () => { + const result = runBootstrap("all"); + assert.doesNotThrow(() => { + const serialised = JSON.stringify(result, null, 2); + assert.ok(serialised.length > 0); }); -}); \ No newline at end of file +}); + +// --------------------------------------------------------------------------- +// Clean-checkout assertions — required files must exist +// --------------------------------------------------------------------------- + +test("clean checkout: backend/package-lock.json exists", () => { + const lockfile = path.join(REPO_ROOT, "backend", "package-lock.json"); + assert.ok( + fs.existsSync(lockfile), + "backend/package-lock.json is missing — `npm ci` will fail on a clean checkout", + ); +}); + +test("clean checkout: frontend/package-lock.json exists", () => { + const lockfile = path.join(REPO_ROOT, "frontend", "package-lock.json"); + assert.ok( + fs.existsSync(lockfile), + "frontend/package-lock.json is missing — `npm ci` will fail on a clean checkout", + ); +}); + +test("clean checkout: soroban/Cargo.toml exists", () => { + const cargoToml = path.join(REPO_ROOT, "soroban", "Cargo.toml"); + assert.ok( + fs.existsSync(cargoToml), + "soroban/Cargo.toml is missing — `cargo check --workspace` will fail on a clean checkout", + ); +}); + +test("clean checkout: backend/.env.example exists", () => { + assert.ok( + fs.existsSync(path.join(REPO_ROOT, "backend", ".env.example")), + "backend/.env.example is missing — contributors have no env var reference", + ); +}); + +test("clean checkout: frontend/.env.example exists", () => { + assert.ok( + fs.existsSync(path.join(REPO_ROOT, "frontend", ".env.example")), + "frontend/.env.example is missing — contributors have no env var reference", + ); +}); + +test("clean checkout: scripts/contributor-bootstrap.js exists", () => { + assert.ok( + fs.existsSync(path.join(REPO_ROOT, "scripts", "contributor-bootstrap.js")), + "scripts/contributor-bootstrap.js is missing", + ); +}); + +test("clean checkout: scripts/ci-local.sh exists", () => { + assert.ok( + fs.existsSync(path.join(REPO_ROOT, "scripts", "ci-local.sh")), + "scripts/ci-local.sh is missing — contributors cannot reproduce CI locally", + ); +}); + +// --------------------------------------------------------------------------- +// Root package.json exposes bootstrap scripts +// --------------------------------------------------------------------------- + +test("root package.json: 'bootstrap' script is defined and invokes contributor-bootstrap.js", () => { + const pkg = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"), + ); + assert.ok( + pkg.scripts && pkg.scripts.bootstrap, + "root package.json must define a 'bootstrap' script", + ); + assert.ok( + pkg.scripts.bootstrap.includes("contributor-bootstrap"), + `bootstrap script must invoke contributor-bootstrap.js, got: ${pkg.scripts.bootstrap}`, + ); +}); + +test("root package.json: 'bootstrap:json' script is defined", () => { + const pkg = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"), + ); + assert.ok( + pkg.scripts && pkg.scripts["bootstrap:json"], + "root package.json must define a 'bootstrap:json' script", + ); +});