diff --git a/commands/listing-install.ts b/commands/listing-install.ts index efa35c99..3c58236b 100644 --- a/commands/listing-install.ts +++ b/commands/listing-install.ts @@ -28,7 +28,9 @@ import { getLoaderService, loadCommandModule } from './utils'; import ExecuteAtomicOperationsCommand from '@cardstack/boxel-host/commands/execute-atomic-operations'; import FetchCardJsonCommand from '@cardstack/boxel-host/commands/fetch-card-json'; import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import ReadBinaryFileCommand from '@cardstack/boxel-host/commands/read-binary-file'; import ReadSourceCommand from '@cardstack/boxel-host/commands/read-source'; +import WriteBinaryFileCommand from '@cardstack/boxel-host/commands/write-binary-file'; import SerializeCardCommand from '@cardstack/boxel-host/commands/serialize-card'; import ValidateRealmCommand from '@cardstack/boxel-host/commands/validate-realm'; @@ -43,9 +45,11 @@ export type InstanceOperation = { }; // file-meta resources are JSON projections of binary files in the source -// realm. The atomic endpoint only accepts card/source types; binaries continue -// to resolve cross-realm via the parent card's relationship data.id, so we -// drop file-meta documents rather than try to copy them. +// realm — they carry no bytes, and the atomic endpoint only accepts +// card/source types. Returning undefined here signals the caller to copy the +// underlying binary via a per-file octet-stream write instead; the parent +// card's relative links.self then resolves to that copy inside the install +// directory. export function buildInstanceOperation( doc: unknown, copyInstanceMeta: CopyInstanceMeta, @@ -161,16 +165,36 @@ export default class ListingInstallCommand extends Command< }), ); + let binaryCopies: { sourceUrl: string; targetPath: string }[] = []; let instanceOperations = await Promise.all( plan.instancesCopy.map(async (copyInstanceMeta: CopyInstanceMeta) => { - let { sourceCard } = copyInstanceMeta; + let { sourceCard, lid } = copyInstanceMeta; let { document: doc } = await new FetchCardJsonCommand( this.commandContext, ).execute({ cardIdentifier: sourceCard.id }); - return buildInstanceOperation(doc, copyInstanceMeta, realmUrl); + let operation = buildInstanceOperation(doc, copyInstanceMeta, realmUrl); + if (!operation) { + binaryCopies.push({ sourceUrl: sourceCard.id, targetPath: lid }); + } + return operation; }), ); + // Binaries are written before the atomic batch so installed cards resolve + // their file links as soon as they index. There is no rollback: an atomic + // failure leaves these copies orphaned in the fresh install directory. + for (let { sourceUrl, targetPath } of binaryCopies) { + let { base64Content, contentType } = await new ReadBinaryFileCommand( + this.commandContext, + ).execute({ fileIdentifier: sourceUrl }); + await new WriteBinaryFileCommand(this.commandContext).execute({ + realm: realmUrl, + path: targetPath, + base64Content, + contentType, + }); + } + const operations = [ ...sourceOperations, ...instanceOperations.filter( diff --git a/tests/helpers/test-fixtures.ts b/tests/helpers/test-fixtures.ts index 54fcd17b..b7b45a43 100644 --- a/tests/helpers/test-fixtures.ts +++ b/tests/helpers/test-fixtures.ts @@ -40,6 +40,17 @@ export const blogPostCardSource = ` } `; +export const photoPostCardSource = ` + import { field, contains, linksTo, CardDef, ImageDef } from 'https://cardstack.com/base/card-api'; + import StringField from 'https://cardstack.com/base/string'; + + export class PhotoPost extends CardDef { + static displayName = 'PhotoPost'; + @field cardTitle = contains(StringField); + @field photo = linksTo(() => ImageDef); + } +`; + export const contactLinkFieldSource = ` import { field, contains, FieldDef } from 'https://cardstack.com/base/card-api'; import StringField from 'https://cardstack.com/base/string'; @@ -104,6 +115,81 @@ export function makeMockCatalogContents( return { 'author/author.gts': authorCardSource, 'blog-post/blog-post.gts': blogPostCardSource, + 'photo-post/photo-post.gts': photoPostCardSource, + 'photo-post/photo.png': makeMinimalPng(), + 'photo-post/PhotoPost/example.json': { + data: { + type: 'card', + attributes: { + cardTitle: 'Photo Post', + }, + relationships: { + // relative links.self mirrors the on-disk shape of catalog content; + // it must resolve inside the install directory after copying + photo: { + links: { self: '../photo.png' }, + data: { + type: 'file-meta', + id: `${mockCatalogURL}photo-post/photo.png`, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${mockCatalogURL}photo-post/photo-post`, + name: 'PhotoPost', + }, + }, + }, + }, + 'Spec/photo-post.json': { + data: { + type: 'card', + attributes: { + ref: { + name: 'PhotoPost', + module: `${mockCatalogURL}photo-post/photo-post`, + }, + }, + specType: 'card', + containedExamples: [], + cardTitle: 'PhotoPost', + cardDescription: 'Spec for PhotoPost card', + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/spec', + name: 'Spec', + }, + }, + }, + }, + 'Listing/photo-post.json': { + data: { + type: 'card', + attributes: { + name: 'Photo Post', + cardTitle: 'Photo Post', // hardcoding title otherwise test will be flaky when waiting for a computed + }, + relationships: { + 'specs.0': { + links: { + self: `${mockCatalogURL}Spec/photo-post`, + }, + }, + 'examples.0': { + links: { + self: `${mockCatalogURL}photo-post/PhotoPost/example`, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'CardListing', + }, + }, + }, + }, 'fields/contact-link/contact-link.gts': contactLinkFieldSource, 'app-card.gts': appCardSource, 'blog-app/blog-app.gts': blogAppCardSource, diff --git a/tests/live/catalog-app/listing-install.test.gts b/tests/live/catalog-app/listing-install.test.gts index 89f82a82..4f67a378 100644 --- a/tests/live/catalog-app/listing-install.test.gts +++ b/tests/live/catalog-app/listing-install.test.gts @@ -36,6 +36,7 @@ const testDestinationRealmURL = `http://test-realm/test2/`; //listing const authorListingId = `${mockCatalogURL}Listing/author`; const blogPostListingId = `${mockCatalogURL}Listing/blog-post`; +const photoPostListingId = `${mockCatalogURL}Listing/photo-post`; export function runTests() { module.skip( @@ -166,6 +167,32 @@ export function runTests() { await verifyFileInFileTree(assert, authorCompanyExamplePath); }); + test('listing installs binary files linked from examples', async function (assert) { + const listingName = 'photo-post'; + + await executeCommand( + ListingInstallCommand, + photoPostListingId, + testDestinationRealmURL, + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let examplePath = `${outerFolder}photo-post/PhotoPost/example.json`; + await openDir(assert, examplePath); + await verifyFileInFileTree(assert, examplePath); + let photoPath = `${outerFolder}photo-post/photo.png`; + await openDir(assert, photoPath); + await verifyFileInFileTree(assert, photoPath); + }); + test('field listing', async function (assert) { const listingName = 'contact-link'; const contactLinkFieldListingCardId = `${mockCatalogURL}FieldListing/contact-link`;