diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index b4a922a5..b3c56fae 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -95,6 +95,14 @@ composer can use it. each of the four commands — rewritten to use it. Recorded in `assets/s2/parity-divergences-s3.md`. +## The ORM family does not work through the assembled binary (found 2026-08-13, writing the e2e happy paths) + +Running the shipped `prisma` binary against a scratch directory, rather than the ORM family through the test harness, turns up three things. The first is a defect a user hits on their first command. + +- **`prisma orm init` scaffolds a project the `prisma` binary cannot read.** It writes `prisma-next.config.ts` — the standalone `prisma-next` bin's config file — and then fails its own last step, `Emit the contract`, with exit 5 and `Config is not a defineConfig result`. Nine files are already on disk at that point. Running any ORM command afterwards fails again, differently: the mounted family reads its configuration from an `orm` section of `prisma.config.ts` (`ormConfigSection`, `packages/1-framework/3-tooling/cli/src/orm/config-section.ts` in prisma/prisma), so it reports `CLI.CONFIG_SECTION_INVALID` and `CONFIG.FILE_NOT_FOUND` — "The orm config section is absent, so prisma-next.config.ts was never evaluated." So `prisma orm init && prisma contract emit` cannot work, and the two config surfaces have different shapes: the section nests the whole config under `orm`, while the scaffolded file exports a `defineConfig` result. Which side moves is the ORM's call; that it is broken today is not in question. +- **Loading a hand-written `prisma.config.ts` failed with `Cannot find package 'pathe'`**, imported by `c12` from `packages/cli-engine/node_modules/c12`. `c12` declares `pathe` and the package is in the workspace store, so this is probably a pnpm layout artifact of running the built binary from inside the monorepo rather than a shipping defect — **but it is unverified**, and if it does reproduce from a packed tarball then every command that reads a config file is broken on install. Worth one run of the S6 tarball check with a config file present. +- **The e2e coverage convention excludes all 22 ORM commands on reasoning #171 disproved.** `tests/e2e-coverage.test.ts` excuses them with "Real e2e lives in prisma/prisma (R7); the shell proves composition in orm-mount.test.ts (R8)." prisma/prisma's suite passed throughout the presentations change while the assembled binary exited 2, and `orm-mount.test.ts` proves composition for exactly one command, `migration list`, not per family. The operator's ruling (2026-08-13) is that every mounted command needs a happy path in this repo, precisely because the product repos cannot reproduce the assembled CLI. The exclusion should become a backlog entry once the first item above is fixed and the commands can run at all. + ## A live bug carried out of the port (found closing PR #92, 2026-08-12) - **The production branch is still resolved by name, not role.** diff --git a/packages/cli/e2e/deployed-service.ts b/packages/cli/e2e/deployed-service.ts new file mode 100644 index 00000000..5417da6a --- /dev/null +++ b/packages/cli/e2e/deployed-service.ts @@ -0,0 +1,204 @@ +/** + * A service with a promoted deployment, for the commands that cannot be + * exercised without one. + * + * `service create` makes a service, and that is as far as the CLI can + * get on its own: every deployment verb, `service open` and the custom + * domain commands act on a deployment, and only Composer produces one. + * So this fixture does what Composer does, in the three steps the + * management API exposes — create a deployment, upload an artifact to + * the pre-signed URL it answers with, then start and promote it through + * the CLI itself. + * + * The artifact is a real tar.gz built here rather than a file checked + * in, because it has to be a byte stream the platform accepts and a + * fixture nobody can accidentally break by editing. + */ +import { gzipSync } from "node:zlib"; + +import type { CliRun, RunOptions } from "./harness"; +import { e2eCredentials } from "./harness"; + +type Runner = { + run: (args: readonly string[], options?: RunOptions) => Promise; +}; + +function apiBaseUrl(): string { + return ( + process.env.PRISMA_MANAGEMENT_API_URL?.trim() || "https://api.prisma.io" + ); +} + +function serviceToken(): string { + const credentials = e2eCredentials(); + if (credentials === null) { + throw new Error("no e2e credentials; this fixture should not have run"); + } + return credentials.serviceToken; +} + +/** One ustar header plus its content, padded to the 512-byte boundary. */ +function tarEntry(name: string, contents: string): Buffer { + const body = Buffer.from(contents, "utf8"); + const header = Buffer.alloc(512); + header.write(name, 0, 100, "utf8"); + header.write("0000644\0", 100, 8, "utf8"); // mode + header.write("0000000\0", 108, 8, "utf8"); // uid + header.write("0000000\0", 116, 8, "utf8"); // gid + header.write(`${body.length.toString(8).padStart(11, "0")}\0`, 124, 12); + header.write("00000000000\0", 136, 12, "utf8"); // mtime, fixed so the + // artifact is byte-identical between runs + header.write(" ", 148, 8, "utf8"); // checksum, spaces while summing + header.write("0", 156, 1, "utf8"); // typeflag: regular file + header.write("ustar\0", 257, 6, "utf8"); + header.write("00", 263, 2, "utf8"); + + let checksum = 0; + for (const byte of header) { + checksum += byte; + } + header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8, "utf8"); + + const padding = Buffer.alloc((512 - (body.length % 512)) % 512); + return Buffer.concat([header, body, padding]); +} + +/** The smallest thing the platform will run: an HTTP server that + * answers, so a started deployment reaches `running` rather than + * crash-looping. */ +function artifact(): Buffer { + const tar = Buffer.concat([ + tarEntry( + "package.json", + '{"name":"e2e-fixture","version":"1.0.0","type":"module","main":"index.js"}', + ), + tarEntry( + "index.js", + 'import{createServer}from"node:http";' + + 'createServer((_,response)=>{response.writeHead(200);response.end("ok")})' + + ".listen(process.env.PORT||3000);", + ), + Buffer.alloc(1024), // two zero blocks end the archive + ]); + return gzipSync(tar); +} + +export interface DeployedService { + readonly serviceId: string; + readonly serviceName: string; + readonly deploymentId: string; +} + +/** Creates a deployment on an existing service and returns its id, + * having uploaded an artifact but not started it. */ +export async function createDeployment(serviceId: string): Promise { + const response = await fetch( + `${apiBaseUrl()}/v1/apps/${serviceId}/deployments`, + { + method: "POST", + headers: { + authorization: `Bearer ${serviceToken()}`, + "content-type": "application/json", + }, + body: JSON.stringify({ portMapping: { http: 3000 } }), + }, + ); + if (!response.ok) { + throw new Error( + `could not create a deployment: HTTP ${response.status} ${await response.text()}`, + ); + } + const created = (await response.json()) as { + data: { id: string; uploadUrl: string | null }; + }; + if (created.data.uploadUrl === null) { + throw new Error("the API created a deployment with no upload URL"); + } + const uploaded = await fetch(created.data.uploadUrl, { + method: "PUT", + body: new Uint8Array(artifact()), + }); + if (!uploaded.ok) { + throw new Error(`artifact upload failed: HTTP ${uploaded.status}`); + } + return created.data.id; +} + +/** + * A service whose deployment is running and live. The two CLI calls are + * deliberate: `start` and `promote` are commands under test, so the + * fixture proves them on the way to setting itself up, and a failure in + * either is reported as a failure to build the fixture rather than as a + * mysterious later assertion. + */ +export async function deployService( + cli: Runner, + serviceName: string, +): Promise { + const created = await cli.run(["service", "create", serviceName]); + const service = ( + created.envelope.result as { service: { id: string; name: string } } + ).service; + + const deploymentId = await createDeployment(service.id); + await cli.run([ + "service", + "deployment", + "start", + deploymentId, + "--service", + serviceName, + ]); + await cli.run([ + "service", + "deployment", + "promote", + deploymentId, + "--service", + serviceName, + ]); + + return { serviceId: service.id, serviceName: service.name, deploymentId }; +} + +/** + * Deletes a deployment, warning rather than throwing. + * + * The scratch project's own teardown cannot do this: `project remove` + * refuses while a deployment exists — "Cannot delete project: active + * deployments exist. Please stop and delete all deployments first." — so + * a file that deploys has to clean up in this order or it strands the + * whole project. + */ +export async function deleteDeployment( + cli: Runner, + deployment: { readonly id: string; readonly serviceName: string }, +): Promise { + try { + const removal = await cli.run( + [ + "service", + "deployment", + "delete", + deployment.id, + "--service", + deployment.serviceName, + "--confirm", + deployment.id, + ], + { expectOk: false }, + ); + if (!removal.envelope.ok) { + console.warn( + `e2e teardown could not delete deployment ${deployment.id}: ` + + `${removal.envelope.error?.code ?? "(no code)"}. The scratch ` + + "project cannot be removed until it is gone.", + ); + } + } catch (failure) { + console.warn( + `e2e teardown could not delete deployment ${deployment.id}: ` + + `${failure instanceof Error ? failure.message : String(failure)}`, + ); + } +} diff --git a/packages/cli/e2e/service-deployment.e2e.ts b/packages/cli/e2e/service-deployment.e2e.ts new file mode 100644 index 00000000..b1206b0e --- /dev/null +++ b/packages/cli/e2e/service-deployment.e2e.ts @@ -0,0 +1,235 @@ +/** + * The deployment verbs, against a service this file deploys to. + * + * Every command here needs a deployment to act on, which is why they + * had no coverage: the CLI cannot make one, and only Composer does. + * `deployed-service.ts` does what Composer does through the management + * API, so these commands can finally be run rather than reasoned about. + * + * The blocks run in file order and share one service: it is deployed + * once, read by the middle blocks, then stopped and deleted at the end. + * Teardown must delete the deployment before the scratch project can go. + */ +import { afterAll, expect, it } from "vitest"; + +import { deleteDeployment, deployService } from "./deployed-service"; +import { scratchName } from "./harness"; +import { useScratchProject } from "./scratch"; +import { describeCommand } from "./suite"; + +const HTTPS_URL = /^https:\/\//; + +const scratch = useScratchProject("service-deployment"); + +let deployed: + | { serviceId: string; serviceName: string; deploymentId: string } + | undefined; + +function requireDeployed(): { + serviceId: string; + serviceName: string; + deploymentId: string; +} { + if (deployed === undefined) { + throw new Error("the deployment fixture did not run"); + } + return deployed; +} + +interface DeploymentRow { + readonly id: string; + readonly status: string; + readonly createdAt: string; + readonly url: string | null; + readonly live: boolean | null; +} + +afterAll(async () => { + if (deployed !== undefined) { + await deleteDeployment(scratch, { + id: deployed.deploymentId, + serviceName: deployed.serviceName, + }); + } +}); + +describeCommand("service deployment promote", () => { + it("deploys a service and promotes the deployment live", async () => { + // `deployService` runs `service deployment start` and then + // `service deployment promote`; both are commands under test, so a + // failure in either fails here rather than somewhere downstream. + deployed = await deployService(scratch, scratchName("dep")); + + const run = await scratch.run([ + "service", + "deployment", + "show", + deployed.deploymentId, + ]); + const shown = run.envelope.result as { deployment: DeploymentRow }; + + expect(shown.deployment.id).toBe(deployed.deploymentId); + expect(shown.deployment.live).toBe(true); + expect(shown.deployment.status).toBe("running"); + }); +}); + +describeCommand("service deployment start", () => { + it("reports the deployment the fixture started as running", async () => { + const existing = requireDeployed(); + // Starting an already-running deployment is the idempotent answer, + // which is the only start this file can make twice. + const run = await scratch.run([ + "service", + "deployment", + "start", + existing.deploymentId, + "--service", + existing.serviceName, + ]); + const started = run.envelope.result as { + readonly deployment: DeploymentRow; + readonly alreadyInState: boolean; + }; + + expect(started.deployment.id).toBe(existing.deploymentId); + expect(started.deployment.status).toBe("running"); + expect(started.alreadyInState).toBe(true); + }); +}); + +describeCommand("service deployment list", () => { + it("lists the deployment, and marks it live", async () => { + const existing = requireDeployed(); + const run = await scratch.run([ + "service", + "deployment", + "list", + "--service", + existing.serviceName, + ]); + const listed = run.envelope.result as { + readonly projectId: string; + readonly service: { readonly id: string }; + readonly deployments: readonly DeploymentRow[]; + }; + + expect(listed.projectId).toBe(scratch.project().id); + expect(listed.service.id).toBe(existing.serviceId); + const found = listed.deployments.find( + (deployment) => deployment.id === existing.deploymentId, + ); + expect(found?.live).toBe(true); + expect(found?.url).toBeTruthy(); + }); +}); + +describeCommand("service deployment show", () => { + it("shows the deployment and the service it belongs to", async () => { + const existing = requireDeployed(); + const run = await scratch.run([ + "service", + "deployment", + "show", + existing.deploymentId, + ]); + const shown = run.envelope.result as { + readonly service: { readonly id: string; readonly name: string }; + readonly deployment: DeploymentRow; + }; + + expect(shown.service.id).toBe(existing.serviceId); + expect(shown.service.name).toBe(existing.serviceName); + expect(shown.deployment.id).toBe(existing.deploymentId); + expect(Date.parse(shown.deployment.createdAt)).not.toBeNaN(); + }); +}); + +describeCommand("service open", () => { + it("answers with the service's URL rather than opening one", async () => { + const existing = requireDeployed(); + // No browser and no TTY in CI, so the command reports the URL it + // would have opened. That it declined to open is part of the + // contract, not an incidental detail. + const run = await scratch.run([ + "service", + "open", + "--service", + existing.serviceName, + ]); + const opened = run.envelope.result as { + readonly service: { readonly id: string }; + readonly url: string; + readonly opened: boolean; + }; + + expect(opened.service.id).toBe(existing.serviceId); + expect(opened.url).toMatch(HTTPS_URL); + expect(opened.opened).toBe(false); + }); +}); + +describeCommand("service deployment stop", () => { + it("stops the running deployment", async () => { + const existing = requireDeployed(); + const run = await scratch.run([ + "service", + "deployment", + "stop", + existing.deploymentId, + "--service", + existing.serviceName, + ]); + const stopped = run.envelope.result as { + readonly deployment: DeploymentRow; + readonly alreadyInState: boolean; + }; + + expect(stopped.deployment.id).toBe(existing.deploymentId); + expect(stopped.deployment.status).toBe("stopped"); + expect(stopped.alreadyInState).toBe(false); + // Stopping takes it out of service, so it is no longer the live one. + expect(stopped.deployment.live).toBeNull(); + }); +}); + +describeCommand("service deployment delete", () => { + it("deletes the deployment, and the listing no longer reports it", async () => { + const existing = requireDeployed(); + const run = await scratch.run([ + "service", + "deployment", + "delete", + existing.deploymentId, + "--service", + existing.serviceName, + "--confirm", + existing.deploymentId, + ]); + const removed = run.envelope.result as { + readonly projectId: string; + readonly deploymentId: string; + readonly deleted: boolean; + }; + + expect(removed.projectId).toBe(scratch.project().id); + expect(removed.deploymentId).toBe(existing.deploymentId); + expect(removed.deleted).toBe(true); + // Teardown has nothing left to remove. + deployed = undefined; + + const after = await scratch.run([ + "service", + "deployment", + "list", + "--service", + existing.serviceName, + ]); + const remaining = after.envelope.result as { + readonly deployments: readonly DeploymentRow[]; + }; + expect( + remaining.deployments.map((deployment) => deployment.id), + ).not.toContain(existing.deploymentId); + }); +}); diff --git a/packages/cli/e2e/service.e2e.ts b/packages/cli/e2e/service.e2e.ts index 5735d005..0e48faff 100644 --- a/packages/cli/e2e/service.e2e.ts +++ b/packages/cli/e2e/service.e2e.ts @@ -95,6 +95,35 @@ describeCommand("service list", () => { }); }); +describeCommand("service show", () => { + it("shows the created service, with nothing deployed to it", async () => { + const existing = requireService(); + const run = await scratch.run([ + "service", + "show", + "--service", + existing.name, + ]); + const shown = run.envelope.result as { + readonly projectId: string; + readonly service: { readonly id: string; readonly name: string }; + readonly liveDeployment: unknown; + readonly liveUrl: string | null; + readonly recentDeployments: readonly unknown[]; + }; + + expect(shown.projectId).toBe(scratch.project().id); + expect(shown.service.id).toBe(existing.id); + expect(shown.service.name).toBe(existing.name); + // `service create` does not deploy, so these three state the same + // fact three ways, and each is a separate chance to invent one: no + // promoted deployment, so no live URL, and no history to show. + expect(shown.liveDeployment).toBeNull(); + expect(shown.liveUrl).toBeNull(); + expect(shown.recentDeployments).toEqual([]); + }); +}); + describeCommand("service remove", () => { it("removes the service, and the listing no longer reports it", async () => { const existing = requireService(); diff --git a/packages/cli/tests/e2e-coverage.test.ts b/packages/cli/tests/e2e-coverage.test.ts index 5cc48b87..20af1b03 100644 --- a/packages/cli/tests/e2e-coverage.test.ts +++ b/packages/cli/tests/e2e-coverage.test.ts @@ -77,12 +77,6 @@ const EXCLUSIONS: Readonly> = { "A session command: it runs until SIGINT or SIGTERM, redeploying on file change, so it has no happy path that terminates on its own.", "composer log": "A session command that streams until interrupted, against an app only `composer deploy` could have deployed.", - "service deployment start": - "Acts on a deployment, and the API only accepts a start once an artifact has been uploaded. Only `composer deploy` produces one, which this suite cannot run.", - "service deployment stop": - "Acts on a deployment, which only `composer deploy` can create here. Same reason as `service deployment start`.", - "service deployment delete": - "Deletes a deployment, which only `composer deploy` can create here. Same reason as `service deployment start`.", }; /** @@ -92,27 +86,29 @@ const EXCLUSIONS: Readonly> = { * here. A command added from today on needs a test or an EXCLUSIONS * entry saying why it cannot have one. * - * These entries are owed for two different reasons, and the difference - * matters to whoever picks one up. + * This list used to hold everything that needed a DEPLOYED service, on + * the grounds that only Composer can make one. It can now be made here: + * `e2e/deployed-service.ts` creates a deployment through the management + * API, uploads an artifact to the pre-signed URL it answers with, and + * starts and promotes it through the CLI. That covered seven commands, + * and what is left needs something the deployment alone does not give. * - * `service open`, the four `service deployment *` commands and - * `build logs` need a service that has been DEPLOYED, which Composer - * does and this repo cannot; covering them needs a fixture service that - * outlives a CI run. + * `service deployment rollback` needs a SECOND promoted deployment to + * roll back from. The fixture makes one; making two and promoting them + * in order is more run time and more teardown, and is the next thing to + * write. * - * `service show` and the five `service domain *` commands need no such - * thing — they act on a service that merely exists, and `service create` - * makes one without deploying. They are simply unwritten. `service show` - * is the easiest of them: D1's unit tests already cover it against a - * service that was never promoted, so a real happy path in - * `e2e/service.e2e.ts` has no remaining obstacle. + * The five `service domain *` commands need a hostname whose DNS we + * control. With a promoted deployment in place, `service domain add` + * gets all the way to `SERVICE.DOMAIN_DNS_NOT_CONFIGURED` — "DNS + * verification failed: ensure the hostname CNAMEs to + * switchboard.ewr.prisma.build." No fixture inside this repo can satisfy + * that; it needs a domain the test account owns and a DNS record. + * + * `build logs` needs a build, which comes from a git push or a Console + * action, not from anything the CLI can do. */ const AWAITING_COVERAGE: readonly string[] = [ - "service show", - "service open", - "service deployment list", - "service deployment show", - "service deployment promote", "service deployment rollback", "service domain add", "service domain show",