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
34 changes: 29 additions & 5 deletions commands/listing-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
86 changes: 86 additions & 0 deletions tests/helpers/test-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`,
Comment on lines +129 to +133

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Good catch — changed in 22ff059: the fixture now uses a relative links.self (../photo.png), matching the on-disk shape of catalog content, with data.id kept absolute as the card GET serves it. That's the exact geometry the fix has to satisfy: the relative link must re-resolve inside the install directory after the binary is copied.

},
},
},
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,
Expand Down
27 changes: 27 additions & 0 deletions tests/live/catalog-app/listing-install.test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Intentional, unfortunately: the whole listing-install live module is skipped because every test in it fails at command.execute() with a card-api identity error while constructing ListingInstallInput — before any install logic runs. Moving the test to another module doesn't help; no live module can execute an install until that harness issue is fixed. The test is added now so it runs the moment the module is unskipped; the fix itself was verified end-to-end by remixing the wine-cellar listing on a local dev stack and checking the copied binaries byte-for-byte.

Expand Down Expand Up @@ -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`;
Expand Down
Loading