Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { openModelStore, type ModelStore } from "./model-store.ts";
import { openNetworkMode } from "./network-mode.ts";
import { openOfflineMediaCache } from "./offline-media-cache.ts";
import { runPackagedAcquisitionProof } from "./packaged-acquisition-proof.ts";
import { PACKAGED_EXPORT_PROOF_ARGUMENT, runPackagedExportProof } from "./packaged-export-proof.ts";
import { PACKAGED_SIDECAR_PROOF_ARGUMENT } from "./packaged-sidecar-proof-constants.ts";
import { packagedProofFailureCode, runPackagedSidecarProof } from "./packaged-sidecar-proof.ts";
import { ARCHIVE_EXTENSION } from "./project-archive-format.ts";
Expand All @@ -56,7 +57,8 @@ import { YouTubeMetadata } from "./youtube-source.ts";

if (
process.argv.includes(PACKAGED_SIDECAR_PROOF_ARGUMENT) ||
process.argv.includes("--open-chords-acquisition-proof")
process.argv.includes("--open-chords-acquisition-proof") ||
process.argv.includes(PACKAGED_EXPORT_PROOF_ARGUMENT)
) {
// Electron otherwise opens a modal error dialog, hiding native CI failures
// behind the outer process timeout. Never continue after an uncaught error.
Expand Down Expand Up @@ -97,9 +99,14 @@ if (
void app
.whenReady()
.then(
process.argv.includes("--open-chords-acquisition-proof")
? runPackagedAcquisitionProof
: runPackagedSidecarProof,
process.argv.includes(PACKAGED_EXPORT_PROOF_ARGUMENT)
? async () => {
const report = await runPackagedExportProof(app.getPath("userData"));
process.stdout.write(`${JSON.stringify(report)}\n`);
}
: process.argv.includes("--open-chords-acquisition-proof")
? runPackagedAcquisitionProof
: runPackagedSidecarProof,
)
.then(
() => app.exit(0),
Expand Down
64 changes: 64 additions & 0 deletions apps/desktop/src/main/packaged-export-proof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";

import { exportTarget, openProjectExports } from "./project-exports.ts";
import { openProjectLibrary } from "./project-library.ts";

export const PACKAGED_EXPORT_PROOF_ARGUMENT = "--open-chords-export-proof";

// CI seeds only synthetic Project data in an isolated user-data directory.
// Fixed targets exercise bundled projections, not native Save dialog selection.
export async function runPackagedExportProof(stateRoot: string) {
const library = await openProjectLibrary({ stateRoot });
const projectId = "project_golden";
const initial = await library.getSnapshot(projectId);
if (!initial || library.listExportReceipts(projectId).length !== 0)
throw new Error("export_proof_fixture_invalid");
const cancelled = await openProjectExports({ library, stateRoot, pickTarget: async () => null });
const cancellation = await cancelled.saveJson({
projectId,
expectedProjectRevisionId: initial.projectRevisionId,
presentation: "current",
});
if (
cancellation.state !== "cancelled" ||
(await library.getSnapshot(projectId))?.projectRevisionId !== initial.projectRevisionId ||
library.listExportReceipts(projectId).length !== 0
)
throw new Error("export_proof_cancellation_failed");

const outputRoot = join(dirname(stateRoot), "packaged-export-output");
await mkdir(outputRoot);
const exports = await openProjectExports({
library,
stateRoot,
pickTarget: async (format) =>
join(
outputRoot,
format === "project_archive" ? "song.ocarchive" : `score.${exportTarget(format).extension}`,
),
});
for (const type of [
"save_json",
"save_archive",
"save_chordpro",
"save_lrc",
"save_pdf",
] as const) {
const snapshot = await library.getSnapshot(projectId);
if (!snapshot) throw new Error("export_proof_project_missing");
const request = { projectId, expectedProjectRevisionId: snapshot.projectRevisionId, type };
const result = await exports.perform(
type === "save_archive"
? { ...request, type, includeMedia: false }
: type === "save_lrc"
? { ...request, type }
: { ...request, type, presentation: "current" },
);
if (result.state !== "saved") throw new Error("export_proof_publication_failed");
}
const reopened = await openProjectLibrary({ stateRoot });
if (reopened.listExportReceipts(projectId).length !== 5)
throw new Error("export_proof_reopen_failed");
return { proof: "installed-exports", cancelledWithoutRevision: true, durableReceipts: 5 };
}
4 changes: 3 additions & 1 deletion docs/development/compatibility-exports.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ JSON is bounded to 32 MiB, ChordPro to 8 MiB, LRC to 4 MiB and PDF to 64 MiB. PD

## Evidence boundaries

Domain projection and public service tests cover exact symbols, omitted lines, hashes, persistent Receipts and unavailable LRC. Renderer CI saves all formats through real IPC with only the external native-picker response substituted. Installed CI reopens all formats' Receipts and checks available controls; that test alone does not demonstrate an actual installed native save-dialog interaction. PDF bytes are also rendered for visual inspection. Tagged structure does not establish PDF/UA, PDF/A, or native screen-reader acceptance, and no such conformance is claimed.
Domain projection and public service tests cover exact symbols, omitted lines, hashes, persistent Receipts and unavailable LRC. Renderer CI saves all formats through real IPC with only the external native-picker response substituted. The installed export journey runs the ZIP executable with an OS-only environment and the explicit `--open-chords-export-proof` flag. It reads a synthetic Project seeded by the host, cancels one export without creating a revision, and invokes the bundled production publication service for JSON, archive, ChordPro, LRC and PDF. Fixed output targets sit outside the protected user-data directory, and an existing output directory is refused. The host checks canonical JSON, golden ChordPro/LRC, the golden PDF hash, embedded fonts and tags, archive validation, Receipt hashes/losses, and exclusion of private fixture markers. A second normal installed launch reopens those Receipts through named IPC and renderer controls.

This journey exercises the installed projections and publication service with fixed test targets; it does not establish native Save dialog interaction. Native execution belongs to GitHub CI, while local checks cover the service harness, build and test discovery only. Current-head native CI must pass before claiming installed evidence. PDF bytes are also rendered for visual inspection. Tagged structure does not establish PDF/UA, PDF/A, or native screen-reader acceptance, and no such conformance is claimed.

## Runtime notices

Expand Down
49 changes: 49 additions & 0 deletions tests/export-proof.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createHash } from "node:crypto";
import { mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { ProjectEnvelopeSchema } from "@open-chords/contracts";
import { expect, it } from "vitest";

import { runPackagedExportProof } from "../apps/desktop/src/main/packaged-export-proof.ts";
import { openProjectLibrary } from "../apps/desktop/src/main/project-library.ts";
import { goldenRecords } from "./support/editor-fixture.ts";
import { leadSheetProject } from "./support/export-fixture.ts";

it("exports the synthetic fixture through the production service with cancelled and durable outcomes", async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), "oc-export-proof-")));
const stateRoot = join(root, "state");
try {
const envelope = ProjectEnvelopeSchema.parse(
JSON.parse(
await readFile("packages/testkit/contracts/v1/valid/project-envelope.json", "utf8"),
),
);
envelope.payload = leadSheetProject();
const library = await openProjectLibrary({ stateRoot });
await library.createProject({ envelope, records: goldenRecords() });
expect(await runPackagedExportProof(stateRoot)).toEqual({
proof: "installed-exports",
cancelledWithoutRevision: true,
durableReceipts: 5,
});
expect(await readFile(join(root, "packaged-export-output/score.cho"), "utf8")).toBe(
await readFile("tests/fixtures/chordpro-golden.cho", "utf8"),
);
expect(await readFile(join(root, "packaged-export-output/score.lrc"), "utf8")).toBe(
"[ti:project_golden]\n[00:00.41]home go\n",
);
expect(
createHash("sha256")
.update(await readFile(join(root, "packaged-export-output/score.pdf")))
.digest("hex"),
).toBe("3c834d61c9f05666fac4090bb5287fa676eaef34d94c463565c076f77a86cefa");
await expect(runPackagedExportProof(stateRoot)).rejects.toThrow("export_proof_fixture_invalid");
expect(
(await openProjectLibrary({ stateRoot })).listExportReceipts("project_golden"),
).toHaveLength(5);
} finally {
await rm(root, { recursive: true, force: true });
}
});
Loading
Loading