diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2b7cf6f..e526df1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,3 +16,19 @@ jobs: contents: write id-token: write packages: read + + registry-database: + name: Public database command smoke test + needs: publish + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: "lts/*" + cache: npm + - run: npm ci + - run: npm run test:registry-database diff --git a/package.json b/package.json index b6b9988..6bfbe8c 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test:changelog": "vp test run -c vitest.config.ts tests/changelog.test.ts", "test:peer-floor": "vp test run -c vitest.integration.config.ts tests/integration/peer-floor.test.ts", + "test:registry-database": "vp test run -c vitest.integration.config.ts tests/integration/registry-database.test.ts", "test:publint": "publint", "pack:check": "npm pack --ignore-scripts --dry-run --json", "test:templates": "vp test run -c vitest.integration.config.ts tests/integration/packed-templates.test.ts", diff --git a/src/bin/database.ts b/src/bin/database.ts index 94ef0fe..7dcc24e 100644 --- a/src/bin/database.ts +++ b/src/bin/database.ts @@ -30,7 +30,7 @@ export async function loadOrmTooling(cwd: string): Promise { entry = path.resolve(path.dirname(manifestPath), relative); } catch (error) { throw new Error( - "This project does not have @askrjs/orm installed. Install it before running `askr database`.", + "This project does not have @askrjs/orm installed. Run `npm install @askrjs/orm` before `askr database`.", { cause: error }, ); } diff --git a/src/generate/generator.ts b/src/generate/generator.ts index 3b1fee4..c7fe6e6 100644 --- a/src/generate/generator.ts +++ b/src/generate/generator.ts @@ -862,8 +862,10 @@ export async function writeGenerated( await rename(stage, output); if (moved) await rm(backup, { recursive: true, force: true }); } catch (error) { - await rm(output, { recursive: true, force: true }); - if (moved) await rename(backup, output); + if (moved) { + await rm(output, { recursive: true, force: true }); + await rename(backup, output); + } throw error; } finally { await rm(stage, { recursive: true, force: true }); diff --git a/tests/database.test.ts b/tests/database.test.ts index 9e3b643..f48edcd 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { loadOrmTooling, @@ -22,6 +25,19 @@ function io() { type Loader = typeof loadOrmTooling; describe("database command routing", () => { + it("should name the exact public ORM install command when tooling is missing", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-missing-orm-")); + try { + await fs.writeFile( + path.join(root, "package.json"), + `${JSON.stringify({ name: "missing-orm", private: true, type: "module" })}\n`, + ); + await expect(loadOrmTooling(root)).rejects.toThrow("npm install @askrjs/orm"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("should forward all semantics to the project-installed ORM tooling", async () => { const output = io(); const runDatabaseCli = vi.fn(async () => 0); diff --git a/tests/generate.test.ts b/tests/generate.test.ts index e2e9b23..b3e2572 100644 --- a/tests/generate.test.ts +++ b/tests/generate.test.ts @@ -190,6 +190,24 @@ describe("askr generate", () => { ); expect(await readFile(output, "utf8")).toBe("keep"); }); + it("should preserve generated output when its backup rename fails", async () => { + const root = await mkdtemp(join(tmpdir(), "askr-stale-backup-")); + const output = join(root, "generated"); + const backup = `${output}.backup-${process.pid}`; + await mkdir(output); + await writeFile(join(output, ".askr-generated.json"), "original manifest\n"); + await writeFile(join(output, "schemas.ts"), "original schema\n"); + await mkdir(backup); + await writeFile(join(backup, "stale.txt"), "stale backup\n"); + + await expect(writeGenerated(output, generateFiles(document), false)).rejects.toThrow(); + + expect(await readFile(join(output, ".askr-generated.json"), "utf8")).toBe( + "original manifest\n", + ); + expect(await readFile(join(output, "schemas.ts"), "utf8")).toBe("original schema\n"); + expect(await readFile(join(backup, "stale.txt"), "utf8")).toBe("stale backup\n"); + }); it("should detect stale and extra files without writes given check mode when checking", async () => { const root = await mkdtemp(join(tmpdir(), "askr-check-")); const output = join(root, "client"); diff --git a/tests/integration/registry-database.test.ts b/tests/integration/registry-database.test.ts new file mode 100644 index 0000000..ee13753 --- /dev/null +++ b/tests/integration/registry-database.test.ts @@ -0,0 +1,73 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import manifest from "../../package.json" with { type: "json" }; + +const execFileAsync = promisify(execFile); + +async function run(command: string, args: string[], cwd: string): Promise { + const { stdout, stderr } = await execFileAsync(command, args, { + cwd, + env: { ...process.env, NO_COLOR: "1" }, + maxBuffer: 20 * 1024 * 1024, + }); + if (stderr) process.stderr.write(stderr); + return stdout; +} + +async function installRegistryArtifacts(root: string, versions: readonly string[]): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= 30; attempt += 1) { + try { + await run( + "npm", + ["install", "--ignore-scripts", "--no-audit", "--no-fund", ...versions], + root, + ); + return; + } catch (error) { + lastError = error; + if (attempt < 30) await new Promise((resolve) => setTimeout(resolve, 10_000)); + } + } + throw lastError; +} + +test("should validate a database using only public registry artifacts", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-registry-database-")); + const cliVersion = process.env.ASKR_CLI_REGISTRY_VERSION ?? manifest.version; + const ormVersion = process.env.ASKR_ORM_REGISTRY_VERSION ?? "0.0.1"; + try { + await fs.writeFile( + path.join(root, "package.json"), + `${JSON.stringify({ name: "askr-registry-database", private: true, type: "module" }, null, 2)}\n`, + ); + await installRegistryArtifacts(root, [ + `@askrjs/cli@${cliVersion}`, + `@askrjs/orm@${ormVersion}`, + ]); + await fs.mkdir(path.join(root, "database")); + await fs.writeFile( + path.join(root, "database", "index.ts"), + `import { defineDatabase } from "@askrjs/orm"; +import { sqlite } from "@askrjs/orm/sqlite"; + +export default defineDatabase({ driver: sqlite({ filename: "./database.sqlite" }), tables: {} }); +`, + ); + + const askr = path.join(root, "node_modules", ".bin", "askr"); + await run(askr, ["database", "generate"], root); + const output = await run(askr, ["database", "validate"], root); + + expect(output).toContain("valid"); + expect(await fs.readFile(path.join(root, "database", "generated.ts"), "utf8")).toContain( + "GeneratedDatabaseArtifact", + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}, 360_000);