From 487c2a106102526f987f8cb49edd84ec36c15c27 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Thu, 23 Jul 2026 19:25:58 +0800 Subject: [PATCH 1/2] Copy FileDef binaries when installing a listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing a listing whose example cards link FileDef binaries (images, audio) dropped the files: the atomic endpoint only accepts card/source resource types, and file-meta resources carry no bytes to write. The copied cards keep their relative links.self (which wins over data.id at deserialization), so they pointed at paths inside the install directory that were never written — broken images on every remix. The install command now partitions file-meta documents out of the atomic batch into per-file binary copies (ReadBinaryFileCommand → WriteBinaryFileCommand, the same octet-stream write path the host upload flow uses) and writes them before the atomic batch. There is no rollback on atomic failure; orphaned copies in the fresh install directory are harmless. Adds a photo-post fixture (module with a linksTo(ImageDef) field, example card, PNG) and a live test asserting the binary lands in the install directory; the install live-test module remains skipped pending the harness input-construction fix. Co-Authored-By: Claude Fable 5 --- commands/listing-install.ts | 34 ++++++-- tests/helpers/test-fixtures.ts | 84 +++++++++++++++++++ .../live/catalog-app/listing-install.test.gts | 27 ++++++ 3 files changed, 140 insertions(+), 5 deletions(-) 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..bcda67a8 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,79 @@ 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: { + photo: { + links: { self: `${mockCatalogURL}photo-post/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`; From 22ff05954d9efdd65b9dbb99a550d88fd43e2d03 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Thu, 23 Jul 2026 19:56:40 +0800 Subject: [PATCH 2/2] Use a relative links.self in the photo-post fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog content stores FileDef links relative on disk, and the relative form is what must re-resolve inside the install directory after copying — the fixture now exercises that shape directly. Co-Authored-By: Claude Fable 5 --- tests/helpers/test-fixtures.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/helpers/test-fixtures.ts b/tests/helpers/test-fixtures.ts index bcda67a8..b7b45a43 100644 --- a/tests/helpers/test-fixtures.ts +++ b/tests/helpers/test-fixtures.ts @@ -124,8 +124,10 @@ export function makeMockCatalogContents( 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: `${mockCatalogURL}photo-post/photo.png` }, + links: { self: '../photo.png' }, data: { type: 'file-meta', id: `${mockCatalogURL}photo-post/photo.png`,