diff --git a/.github/workflows/ci-test.yaml b/.github/workflows/ci-test.yaml index f514b6d..2c3d38d 100644 --- a/.github/workflows/ci-test.yaml +++ b/.github/workflows/ci-test.yaml @@ -24,6 +24,7 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 with: repository: cardstack/boxel + ref: split-out-commands # Checkout this catalog repo into a temp location - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 @@ -36,6 +37,9 @@ jobs: - uses: ./.github/actions/init + - name: List catalog-realm files + run: find packages/catalog-realm -type f | sort + - name: Build common dependencies run: pnpm run build-common-deps diff --git a/catalog-app-commands.test.gts b/catalog-app-commands.test.gts new file mode 100644 index 0000000..a8a761f --- /dev/null +++ b/catalog-app-commands.test.gts @@ -0,0 +1,990 @@ +import { waitFor, settled } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, skip, test } from 'qunit'; + +import { ensureTrailingSlash } from '@cardstack/runtime-common'; + +import ListingCreateCommand from '@cardstack/boxel-host/commands/listing-create'; +import ListingInstallCommand from '@cardstack/boxel-host/commands/listing-install'; +import ListingRemixCommand from '@cardstack/boxel-host/commands/listing-remix'; +import ListingUseCommand from '@cardstack/boxel-host/commands/listing-use'; + +import ENV from '@cardstack/host/config/environment'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; + +import { + setupLocalIndexing, + setupOnSave, + testRealmURL as mockCatalogURL, + setupAuthEndpoints, + setupUserSubscription, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + visitOperatorMode, + verifySubmode, + toggleFileTree, + openDir, + verifyFolderWithUUIDInFileTree, + verifyFileInFileTree, + verifyJSONWithUUIDInFolder, + setupRealmServerEndpoints, + setCatalogRealmURL, +} from '@cardstack/host/tests/helpers'; +import { setupMockMatrix } from '@cardstack/host/tests/helpers/mock-matrix'; +import { setupApplicationTest } from '@cardstack/host/tests/helpers/setup'; + +import type { CardListing } from '@cardstack/catalog/listing/listing'; + +import { + makeMockCatalogContents, + makeDestinationRealmContents, +} from './catalog-app-test-fixtures'; + +const catalogRealmURL = ensureTrailingSlash(ENV.resolvedCatalogRealmURL); +const testDestinationRealmURL = `http://test-realm/test2/`; + +//listing +const authorListingId = `${mockCatalogURL}Listing/author`; +const pirateSkillListingId = `${mockCatalogURL}SkillListing/pirate-skill`; +const apiDocumentationStubListingId = `${mockCatalogURL}Listing/api-documentation-stub`; +const themeListingId = `${mockCatalogURL}ThemeListing/cardstack-theme`; +const blogPostListingId = `${mockCatalogURL}Listing/blog-post`; +//license +const mitLicenseId = `${mockCatalogURL}License/mit`; +//category +const writingCategoryId = `${mockCatalogURL}Category/writing`; + +//tags +const calculatorTagId = `${mockCatalogURL}Tag/c1fe433a-b3df-41f4-bdcf-d98686ee42d7`; + +export function runTests() { + module( + 'Acceptance | Catalog | catalog app - commands tests', + function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [mockCatalogURL, testDestinationRealmURL], + }); + + let { createAndJoinRoom } = mockMatrixUtils; + + hooks.beforeEach(async function () { + createAndJoinRoom({ + sender: '@testuser:localhost', + name: 'room-test', + }); + setupUserSubscription(); + setupAuthEndpoints(); + setCatalogRealmURL(mockCatalogURL, catalogRealmURL); + // this setup test realm is pretending to be a mock catalog + await setupAcceptanceTestRealm({ + realmURL: mockCatalogURL, + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeMockCatalogContents(mockCatalogURL, catalogRealmURL), + }, + }); + await setupAcceptanceTestRealm({ + mockMatrixUtils, + realmURL: testDestinationRealmURL, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeDestinationRealmContents(), + }, + }); + }); + + /** + * Waits for a card to appear on the stack with optional title verification + */ + async function waitForCardOnStack( + cardId: string, + expectedTitle?: string, + ) { + await waitFor( + `[data-test-stack-card="${cardId}"] [data-test-boxel-card-header-title]`, + ); + if (expectedTitle) { + await waitFor( + `[data-test-stack-card="${cardId}"] [data-test-boxel-card-header-title]`, + ); + } + } + + async function executeCommand( + commandClass: + | typeof ListingUseCommand + | typeof ListingInstallCommand + | typeof ListingRemixCommand, + listingUrl: string, + realm: string, + ) { + const commandService = getService('command-service'); + const store = getService('store'); + + const command = new commandClass(commandService.commandContext); + const listing = (await store.get(listingUrl)) as CardDef; + + return command.execute({ + realm, + listing, + }); + } + + module('listing commands', function (hooks) { + hooks.beforeEach(async function () { + // we always run a command inside interact mode + await visitOperatorMode({ + stacks: [[]], + }); + }); + module('"build"', function () { + test('card listing', async function (assert) { + await visitOperatorMode({ + stacks: [ + [ + { + id: apiDocumentationStubListingId, + format: 'isolated', + }, + ], + ], + }); + await waitFor( + `[data-test-card="${apiDocumentationStubListingId}"]`, + ); + assert + .dom( + `[data-test-card="${apiDocumentationStubListingId}"] [data-test-catalog-listing-action="Build"]`, + ) + .containsText('Build', 'Build button exist in listing'); + }); + }); + skip('"create"', function (hooks) { + // Mock proxy LLM endpoint only for create-related tests + setupRealmServerEndpoints(hooks, [ + { + route: '_request-forward', + getResponse: async (req: Request) => { + try { + const body = await req.json(); + if ( + body.url === 'https://openrouter.ai/api/v1/chat/completions' + ) { + let requestBody: any = {}; + try { + requestBody = body.requestBody + ? JSON.parse(body.requestBody) + : {}; + } catch { + // ignore parse failure + } + const messages = requestBody.messages || []; + const system: string = + messages.find((m: any) => m.role === 'system')?.content || + ''; + const user: string = + messages.find((m: any) => m.role === 'user')?.content || + ''; + const systemLower = system.toLowerCase(); + let content: string | undefined; + if ( + systemLower.includes( + 'respond only with one token: card, app, skill, or theme', + ) + ) { + // Heuristic moved from production code into test mock: + // If the serialized example or prompts reference an App construct + // (e.g. AppCard base class, module paths with /App/, or a name ending with App) + // then classify as 'app'. If it references Skill, classify as 'skill'. + const userLower = user.toLowerCase(); + if ( + /(appcard|blogapp|"appcard"|\.appcard|name: 'appcard')/.test( + userLower, + ) + ) { + content = 'app'; + } else if ( + /(cssvariables|css imports|theme card|themecreator|theme listing)/.test( + userLower, + ) + ) { + content = 'theme'; + } else if (/skill/.test(userLower)) { + content = 'skill'; + } else { + content = 'card'; + } + } else if (systemLower.includes('catalog listing title')) { + content = 'Mock Listing Title'; + } else if (systemLower.includes('spec-style summary')) { + content = 'Mock listing summary sentence.'; + } else if ( + systemLower.includes("boxel's sample data assistant") + ) { + content = JSON.stringify({ + examples: [ + { + label: 'Generated field value', + url: 'https://example.com/contact', + }, + ], + }); + } else if (systemLower.includes('representing tag')) { + // Deterministic tag selection + content = JSON.stringify([calculatorTagId]); + } else if (systemLower.includes('representing category')) { + // Deterministic category selection + content = JSON.stringify([writingCategoryId]); + } else if (systemLower.includes('representing license')) { + // Deterministic license selection + content = JSON.stringify([mitLicenseId]); + } + + return new Response( + JSON.stringify({ + choices: [ + { + message: { + content, + }, + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + } catch (e) { + return new Response( + JSON.stringify({ + error: 'mock forward error', + details: (e as Error).message, + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + return new Response( + JSON.stringify({ error: 'Unknown proxy path' }), + { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }, + ); + }, + }, + ]); + test('card listing with single dependency module', async function (assert) { + const cardId = mockCatalogURL + 'author/Author/example'; + const commandService = getService('command-service'); + const command = new ListingCreateCommand( + commandService.commandContext, + ); + const result = await command.execute({ + openCardId: cardId, + codeRef: { + module: `${mockCatalogURL}author/author.gts`, + name: 'Author', + }, + targetRealm: mockCatalogURL, + }); + const interim = result?.listing as any; + assert.ok(interim, 'Interim listing exists'); + assert.strictEqual((interim as any).name, 'Mock Listing Title'); + assert.strictEqual( + (interim as any).summary, + 'Mock listing summary sentence.', + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${mockCatalogURL}index`, + }); + await verifySubmode(assert, 'code'); + const instanceFolder = 'CardListing/'; + await openDir(assert, instanceFolder); + const listingId = await verifyJSONWithUUIDInFolder( + assert, + instanceFolder, + ); + if (listingId) { + const listing = (await getService('store').get( + listingId, + )) as CardListing; + assert.ok(listing, 'Listing should be created'); + // Assertions for AI generated fields coming from proxy mock + assert.strictEqual( + (listing as any).name, + 'Mock Listing Title', + 'Listing name populated from autoPatchName mock response', + ); + assert.strictEqual( + (listing as any).summary, + 'Mock listing summary sentence.', + 'Listing summary populated from autoPatchSummary mock response', + ); + assert.strictEqual( + listing.specs.length, + 2, + 'Listing should have two specs', + ); + assert.true( + listing.specs.some((spec) => spec.ref.name === 'Author'), + 'Listing should have an Author spec', + ); + assert.true( + listing.specs.some((spec) => spec.ref.name === 'AuthorCompany'), + 'Listing should have an AuthorCompany spec', + ); + // Deterministic autoLink assertions from proxy mock + assert.ok((listing as any).license, 'License linked'); + assert.strictEqual( + (listing as any).license.id, + mitLicenseId, + 'License id matches mitLicenseId', + ); + assert.ok( + Array.isArray((listing as any).tags), + 'Tags array exists', + ); + assert.true( + (listing as any).tags.some( + (t: any) => t.id === calculatorTagId, + ), + 'Contains calculator tag id', + ); + assert.ok( + Array.isArray((listing as any).categories), + 'Categories array exists', + ); + assert.true( + (listing as any).categories.some( + (c: any) => c.id === writingCategoryId, + ), + 'Contains writing category id', + ); + } + }); + + test('listing will only create specs with recognised imports from realms it can read from', async function (assert) { + const cardId = mockCatalogURL + 'UnrecognisedImports/example'; + const commandService = getService('command-service'); + const command = new ListingCreateCommand( + commandService.commandContext, + ); + await command.execute({ + openCardId: cardId, + codeRef: { + module: `${mockCatalogURL}card-with-unrecognised-imports.gts`, + name: 'UnrecognisedImports', + }, + targetRealm: mockCatalogURL, + }); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${mockCatalogURL}index`, + }); + await verifySubmode(assert, 'code'); + const instanceFolder = 'CardListing/'; + await openDir(assert, instanceFolder); + const listingId = await verifyJSONWithUUIDInFolder( + assert, + instanceFolder, + ); + if (listingId) { + const listing = (await getService('store').get( + listingId, + )) as CardListing; + assert.ok(listing, 'Listing should be created'); + assert.true( + listing.specs.every( + (spec) => + spec.ref.module != + 'https://cdn.jsdelivr.net/npm/chess.js/+esm', + ), + 'Listing should does not have unrecognised import', + ); + } + }); + + test('app listing', async function (assert) { + const cardId = mockCatalogURL + 'blog-app/BlogApp/example'; + const commandService = getService('command-service'); + const command = new ListingCreateCommand( + commandService.commandContext, + ); + const createResult = await command.execute({ + openCardId: cardId, + codeRef: { + module: `${mockCatalogURL}blog-app/blog-app.gts`, + name: 'BlogApp', + }, + targetRealm: testDestinationRealmURL, + }); + // Assert store-level (in-memory) results BEFORE navigating to code mode + let immediateListing = createResult?.listing as any; + assert.ok(immediateListing, 'Listing object returned from command'); + assert.strictEqual( + immediateListing.name, + 'Mock Listing Title', + 'Immediate listing has patched name before persistence', + ); + assert.strictEqual( + immediateListing.summary, + 'Mock listing summary sentence.', + 'Immediate listing has patched summary before persistence', + ); + assert.ok( + immediateListing.license, + 'Immediate listing has linked license before persistence', + ); + assert.strictEqual( + immediateListing.license?.id, + mitLicenseId, + 'Immediate listing license id matches mitLicenseId', + ); + // Lint: avoid logical expression inside assertion + assert.ok( + Array.isArray(immediateListing.tags), + 'Immediate listing tags is an array before persistence', + ); + if (Array.isArray(immediateListing.tags)) { + assert.ok( + immediateListing.tags.length > 0, + 'Immediate listing has linked tag(s) before persistence', + ); + } + assert.true( + immediateListing.tags.some((t: any) => t.id === calculatorTagId), + 'Immediate listing includes calculator tag id', + ); + assert.ok( + Array.isArray(immediateListing.categories), + 'Immediate listing categories is an array before persistence', + ); + if (Array.isArray(immediateListing.categories)) { + assert.ok( + immediateListing.categories.length > 0, + 'Immediate listing has linked category(ies) before persistence', + ); + } + assert.true( + immediateListing.categories.some( + (c: any) => c.id === writingCategoryId, + ), + 'Immediate listing includes writing category id', + ); + assert.ok( + Array.isArray(immediateListing.specs), + 'Immediate listing specs is an array before persistence', + ); + if (Array.isArray(immediateListing.specs)) { + assert.strictEqual( + immediateListing.specs.length, + 5, + 'Immediate listing has expected number of specs before persistence', + ); + } + assert.ok( + Array.isArray(immediateListing.examples), + 'Immediate listing examples is an array before persistence', + ); + if (Array.isArray(immediateListing.examples)) { + assert.strictEqual( + immediateListing.examples.length, + 1, + 'Immediate listing has expected examples before persistence', + ); + } + // Header/title: wait for persisted id (listing.id) then assert via stack card selector + const persistedId = immediateListing.id; + assert.ok(persistedId, 'Immediate listing has a persisted id'); + await waitForCardOnStack(persistedId); + assert + .dom( + `[data-test-stack-card="${persistedId}"] [data-test-boxel-card-header-title]`, + ) + .containsText( + 'Mock Listing Title', + 'Isolated view shows patched name (persisted id)', + ); + // Summary section + assert + .dom('[data-test-catalog-listing-embedded-summary-section]') + .containsText( + 'Mock listing summary sentence.', + 'Isolated view shows patched summary', + ); + + // License section should not show fallback text + assert + .dom('[data-test-catalog-listing-embedded-license-section]') + .doesNotContainText( + 'No License Provided', + 'License section populated (autoLinkLicense)', + ); + + // Tags section + assert + .dom('[data-test-catalog-listing-embedded-tags-section]') + .doesNotContainText( + 'No Tags Provided', + 'Tags section populated (autoLinkTag)', + ); + + // Categories section + assert + .dom('[data-test-catalog-listing-embedded-categories-section]') + .doesNotContainText( + 'No Categories Provided', + 'Categories section populated (autoLinkCategory)', + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + await verifySubmode(assert, 'code'); + const instanceFolder = 'AppListing/'; + await openDir(assert, instanceFolder); + const persistedListingId = await verifyJSONWithUUIDInFolder( + assert, + instanceFolder, + ); + if (persistedListingId) { + const listing = (await getService('store').get( + persistedListingId, + )) as CardListing; + assert.ok(listing, 'Listing should be created'); + assert.strictEqual( + listing.specs.length, + 5, + 'Listing should have five specs', + ); + [ + 'Author', + 'AuthorCompany', + 'BlogPost', + 'BlogApp', + 'AppCard', + ].forEach((specName) => { + assert.true( + listing.specs.some((spec) => spec.ref.name === specName), + `Listing should have a ${specName} spec`, + ); + }); + assert.strictEqual( + listing.examples.length, + 1, + 'Listing should have one example', + ); + + // Assert autoPatch fields populated (from proxy mock responses) + assert.strictEqual( + (listing as any).name, + 'Mock Listing Title', + 'autoPatchName populated listing.name', + ); + assert.strictEqual( + (listing as any).summary, + 'Mock listing summary sentence.', + 'autoPatchSummary populated listing.summary', + ); + + // Basic object-level sanity for autoLink fields (they should exist, may be arrays) + assert.ok( + (listing as any).license, + 'autoLinkLicense populated listing.license', + ); + assert.strictEqual( + (listing as any).license?.id, + mitLicenseId, + 'Persisted listing license id matches mitLicenseId', + ); + assert.ok( + Array.isArray((listing as any).tags), + 'autoLinkTag populated listing.tags array', + ); + if (Array.isArray((listing as any).tags)) { + assert.ok( + (listing as any).tags.length > 0, + 'autoLinkTag populated listing.tags with at least one tag', + ); + } + assert.true( + (listing as any).tags.some( + (t: any) => t.id === calculatorTagId, + ), + 'Persisted listing includes calculator tag id', + ); + assert.ok( + Array.isArray((listing as any).categories), + 'autoLinkCategory populated listing.categories array', + ); + if (Array.isArray((listing as any).categories)) { + assert.ok( + (listing as any).categories.length > 0, + 'autoLinkCategory populated listing.categories with at least one category', + ); + } + assert.true( + (listing as any).categories.some( + (c: any) => c.id === writingCategoryId, + ), + 'Persisted listing includes writing category id', + ); + } + }); + + test('after create command, listing card opens on stack in interact mode', async function (assert) { + const cardId = mockCatalogURL + 'author/Author/example'; + const commandService = getService('command-service'); + const command = new ListingCreateCommand( + commandService.commandContext, + ); + + let r = await command.execute({ + openCardId: cardId, + codeRef: { + module: `${mockCatalogURL}author/author.gts`, + name: 'Author', + }, + targetRealm: mockCatalogURL, + }); + + await verifySubmode(assert, 'interact'); + const listing = r?.listing as any; + const createdId = listing.id; + assert.ok(createdId, 'Listing id should be present'); + await waitForCardOnStack(createdId); + assert + .dom(`[data-test-stack-card="${createdId}"]`) + .exists( + 'Created listing card (by persisted id) is displayed on stack after command execution', + ); + }); + }); + skip('"use"', async function () { + skip('card listing', async function (assert) { + const listingName = 'author'; + const listingId = mockCatalogURL + 'Listing/author.json'; + await executeCommand( + ListingUseCommand, + listingId, + testDestinationRealmURL, + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + + let instanceFolder = `${outerFolder}Author/`; + await openDir(assert, instanceFolder); + await verifyJSONWithUUIDInFolder(assert, instanceFolder); + }); + }); + module('"install"', function () { + test('card listing', async function (assert) { + const listingName = 'author'; + + await executeCommand( + ListingInstallCommand, + authorListingId, + testDestinationRealmURL, + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let gtsFilePath = `${outerFolder}${listingName}/author.gts`; + await openDir(assert, gtsFilePath); + await verifyFileInFileTree(assert, gtsFilePath); + let examplePath = `${outerFolder}${listingName}/Author/example.json`; + await openDir(assert, examplePath); + await verifyFileInFileTree(assert, examplePath); + }); + + test('listing installs relationships of examples and its modules', async function (assert) { + const listingName = 'blog-post'; + + await executeCommand( + ListingInstallCommand, + blogPostListingId, + testDestinationRealmURL, + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let blogPostModulePath = `${outerFolder}blog-post/blog-post.gts`; + let authorModulePath = `${outerFolder}author/author.gts`; + await openDir(assert, blogPostModulePath); + await verifyFileInFileTree(assert, blogPostModulePath); + await openDir(assert, authorModulePath); + await verifyFileInFileTree(assert, authorModulePath); + + let blogPostExamplePath = `${outerFolder}blog-post/BlogPost/example.json`; + let authorExamplePath = `${outerFolder}author/Author/example.json`; + let authorCompanyExamplePath = `${outerFolder}author/AuthorCompany/example.json`; + await openDir(assert, blogPostExamplePath); + await verifyFileInFileTree(assert, blogPostExamplePath); + await openDir(assert, authorExamplePath); + await verifyFileInFileTree(assert, authorExamplePath); + await openDir(assert, authorCompanyExamplePath); + await verifyFileInFileTree(assert, authorCompanyExamplePath); + }); + + test('field listing', async function (assert) { + const listingName = 'contact-link'; + const contactLinkFieldListingCardId = `${mockCatalogURL}FieldListing/contact-link`; + + await executeCommand( + ListingInstallCommand, + contactLinkFieldListingCardId, + testDestinationRealmURL, + ); + + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + // contact-link-[uuid]/ + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + await openDir(assert, `${outerFolder}fields/contact-link.gts`); + let gtsFilePath = `${outerFolder}fields/contact-link.gts`; + await verifyFileInFileTree(assert, gtsFilePath); + }); + + test('skill listing', async function (assert) { + const listingName = 'pirate-skill'; + const listingId = `${mockCatalogURL}SkillListing/${listingName}`; + await executeCommand( + ListingInstallCommand, + listingId, + testDestinationRealmURL, + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instancePath = `${outerFolder}Skill/pirate-speak.json`; + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + }); + }); + module('"remix"', function () { + test('card listing: installs the card and redirects to code mode with persisted playground selection for first example successfully', async function (assert) { + const listingName = 'author'; + const listingId = `${mockCatalogURL}Listing/${listingName}`; + await visitOperatorMode({ + stacks: [[]], + }); + await executeCommand( + ListingRemixCommand, + listingId, + testDestinationRealmURL, + ); + await settled(); + await verifySubmode(assert, 'code'); + await toggleFileTree(); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instanceFile = `${outerFolder}${listingName}/Author/example.json`; + await openDir(assert, instanceFile); + await verifyFileInFileTree(assert, instanceFile); + let gtsFilePath = `${outerFolder}${listingName}/author.gts`; + await openDir(assert, gtsFilePath); + await verifyFileInFileTree(assert, gtsFilePath); + await settled(); + assert + .dom( + '[data-test-playground-panel] [data-test-boxel-card-header-title]', + ) + .hasText('Author - Mike Dane'); + }); + test('skill listing: installs the card and redirects to code mode with preview on first skill successfully', async function (assert) { + const listingName = 'pirate-skill'; + const listingId = `${mockCatalogURL}SkillListing/${listingName}`; + await executeCommand( + ListingRemixCommand, + listingId, + testDestinationRealmURL, + ); + await settled(); + await verifySubmode(assert, 'code'); + await toggleFileTree(); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instancePath = `${outerFolder}Skill/pirate-speak.json`; + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + let cardId = + testDestinationRealmURL + instancePath.replace('.json', ''); + await waitFor('[data-test-card-resource-loaded]'); + assert + .dom(`[data-test-code-mode-card-renderer-header="${cardId}"]`) + .exists(); + }); + test('theme listing: installs the theme example and redirects to code mode successfully', async function (assert) { + const listingName = 'cardstack-theme'; + await executeCommand( + ListingRemixCommand, + themeListingId, + testDestinationRealmURL, + ); + await settled(); + await verifySubmode(assert, 'code'); + await toggleFileTree(); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instancePath = `${outerFolder}theme/theme-example.json`; + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + let cardId = + testDestinationRealmURL + instancePath.replace('.json', ''); + await waitFor('[data-test-card-resource-loaded]'); + assert + .dom(`[data-test-code-mode-card-renderer-header="${cardId}"]`) + .exists(); + }); + }); + + skip('"use" is successful even if target realm does not have a trailing slash', async function (assert) { + const listingName = 'author'; + const listingId = mockCatalogURL + 'Listing/author.json'; + await executeCommand( + ListingUseCommand, + listingId, + removeTrailingSlash(testDestinationRealmURL), + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + + let instanceFolder = `${outerFolder}Author`; + await openDir(assert, instanceFolder); + await verifyJSONWithUUIDInFolder(assert, instanceFolder); + }); + + test('"install" is successful even if target realm does not have a trailing slash', async function (assert) { + const listingName = 'author'; + await executeCommand( + ListingInstallCommand, + authorListingId, + removeTrailingSlash(testDestinationRealmURL), + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + + let gtsFilePath = `${outerFolder}${listingName}/author.gts`; + await openDir(assert, gtsFilePath); + await verifyFileInFileTree(assert, gtsFilePath); + let instancePath = `${outerFolder}${listingName}/Author/example.json`; + + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + }); + + test('"remix" is successful even if target realm does not have a trailing slash', async function (assert) { + const listingName = 'author'; + const listingId = `${mockCatalogURL}Listing/${listingName}`; + await visitOperatorMode({ + stacks: [[]], + }); + await executeCommand( + ListingRemixCommand, + listingId, + removeTrailingSlash(testDestinationRealmURL), + ); + await settled(); + await verifySubmode(assert, 'code'); + await toggleFileTree(); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instancePath = `${outerFolder}${listingName}/Author/example.json`; + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + let gtsFilePath = `${outerFolder}${listingName}/author.gts`; + await openDir(assert, gtsFilePath); + await verifyFileInFileTree(assert, gtsFilePath); + await settled(); + assert + .dom( + '[data-test-playground-panel] [data-test-boxel-card-header-title]', + ) + .hasText('Author - Mike Dane'); + }); + }); + }, + ); +} + +function removeTrailingSlash(url: string): string { + if (url === undefined || url === null) { + throw new Error(`removeTrailingSlash called with invalid url: ${url}`); + } + return url.endsWith('/') && url.length > 1 ? url.slice(0, -1) : url; +} diff --git a/catalog-app-listing-create.test.gts b/catalog-app-listing-create.test.gts new file mode 100644 index 0000000..e5689a8 --- /dev/null +++ b/catalog-app-listing-create.test.gts @@ -0,0 +1,651 @@ +import { waitFor } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, skip, test } from 'qunit'; + +import { ensureTrailingSlash } from '@cardstack/runtime-common'; + +import ListingCreateCommand from '@cardstack/boxel-host/commands/listing-create'; + +import ENV from '@cardstack/host/config/environment'; + +import { + setupLocalIndexing, + setupOnSave, + testRealmURL as mockCatalogURL, + setupAuthEndpoints, + setupUserSubscription, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + visitOperatorMode, + verifySubmode, + openDir, + verifyJSONWithUUIDInFolder, + setupRealmServerEndpoints, + setCatalogRealmURL, +} from '@cardstack/host/tests/helpers'; +import { setupMockMatrix } from '@cardstack/host/tests/helpers/mock-matrix'; +import { setupApplicationTest } from '@cardstack/host/tests/helpers/setup'; + +import type { CardListing } from '@cardstack/catalog/listing/listing'; + +import { + makeMockCatalogContents, + makeDestinationRealmContents, +} from './catalog-app-test-fixtures'; + +const catalogRealmURL = ensureTrailingSlash(ENV.resolvedCatalogRealmURL); +const testDestinationRealmURL = `http://test-realm/test2/`; + +//listing +const apiDocumentationStubListingId = `${mockCatalogURL}Listing/api-documentation-stub`; +//license +const mitLicenseId = `${mockCatalogURL}License/mit`; +//category +const writingCategoryId = `${mockCatalogURL}Category/writing`; + +//tags +const calculatorTagId = `${mockCatalogURL}Tag/c1fe433a-b3df-41f4-bdcf-d98686ee42d7`; + +export function runTests() { + module( + 'Acceptance | Catalog | catalog app - listing create', + function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [mockCatalogURL, testDestinationRealmURL], + }); + + let { createAndJoinRoom } = mockMatrixUtils; + + hooks.beforeEach(async function () { + createAndJoinRoom({ + sender: '@testuser:localhost', + name: 'room-test', + }); + setupUserSubscription(); + setupAuthEndpoints(); + setCatalogRealmURL(mockCatalogURL, catalogRealmURL); + // this setup test realm is pretending to be a mock catalog + await setupAcceptanceTestRealm({ + realmURL: mockCatalogURL, + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeMockCatalogContents(mockCatalogURL, catalogRealmURL), + }, + }); + await setupAcceptanceTestRealm({ + mockMatrixUtils, + realmURL: testDestinationRealmURL, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeDestinationRealmContents(), + }, + }); + }); + + /** + * Waits for a card to appear on the stack with optional title verification + */ + async function waitForCardOnStack( + cardId: string, + expectedTitle?: string, + ) { + await waitFor( + `[data-test-stack-card="${cardId}"] [data-test-boxel-card-header-title]`, + ); + if (expectedTitle) { + await waitFor( + `[data-test-stack-card="${cardId}"] [data-test-boxel-card-header-title]`, + ); + } + } + + module('listing commands', function (hooks) { + hooks.beforeEach(async function () { + // we always run a command inside interact mode + await visitOperatorMode({ + stacks: [[]], + }); + }); + module('"build"', function () { + test('card listing', async function (assert) { + await visitOperatorMode({ + stacks: [ + [ + { + id: apiDocumentationStubListingId, + format: 'isolated', + }, + ], + ], + }); + await waitFor( + `[data-test-card="${apiDocumentationStubListingId}"]`, + ); + assert + .dom( + `[data-test-card="${apiDocumentationStubListingId}"] [data-test-catalog-listing-action="Build"]`, + ) + .containsText('Build', 'Build button exist in listing'); + }); + }); + skip('"create"', function (hooks) { + // Mock proxy LLM endpoint only for create-related tests + setupRealmServerEndpoints(hooks, [ + { + route: '_request-forward', + getResponse: async (req: Request) => { + try { + const body = await req.json(); + if ( + body.url === 'https://openrouter.ai/api/v1/chat/completions' + ) { + let requestBody: any = {}; + try { + requestBody = body.requestBody + ? JSON.parse(body.requestBody) + : {}; + } catch { + // ignore parse failure + } + const messages = requestBody.messages || []; + const system: string = + messages.find((m: any) => m.role === 'system')?.content || + ''; + const user: string = + messages.find((m: any) => m.role === 'user')?.content || + ''; + const systemLower = system.toLowerCase(); + let content: string | undefined; + if ( + systemLower.includes( + 'respond only with one token: card, app, skill, or theme', + ) + ) { + // Heuristic moved from production code into test mock: + // If the serialized example or prompts reference an App construct + // (e.g. AppCard base class, module paths with /App/, or a name ending with App) + // then classify as 'app'. If it references Skill, classify as 'skill'. + const userLower = user.toLowerCase(); + if ( + /(appcard|blogapp|"appcard"|\.appcard|name: 'appcard')/.test( + userLower, + ) + ) { + content = 'app'; + } else if ( + /(cssvariables|css imports|theme card|themecreator|theme listing)/.test( + userLower, + ) + ) { + content = 'theme'; + } else if (/skill/.test(userLower)) { + content = 'skill'; + } else { + content = 'card'; + } + } else if (systemLower.includes('catalog listing title')) { + content = 'Mock Listing Title'; + } else if (systemLower.includes('spec-style summary')) { + content = 'Mock listing summary sentence.'; + } else if ( + systemLower.includes("boxel's sample data assistant") + ) { + content = JSON.stringify({ + examples: [ + { + label: 'Generated field value', + url: 'https://example.com/contact', + }, + ], + }); + } else if (systemLower.includes('representing tag')) { + // Deterministic tag selection + content = JSON.stringify([calculatorTagId]); + } else if (systemLower.includes('representing category')) { + // Deterministic category selection + content = JSON.stringify([writingCategoryId]); + } else if (systemLower.includes('representing license')) { + // Deterministic license selection + content = JSON.stringify([mitLicenseId]); + } + + return new Response( + JSON.stringify({ + choices: [ + { + message: { + content, + }, + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + } catch (e) { + return new Response( + JSON.stringify({ + error: 'mock forward error', + details: (e as Error).message, + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + return new Response( + JSON.stringify({ error: 'Unknown proxy path' }), + { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }, + ); + }, + }, + ]); + test('card listing with single dependency module', async function (assert) { + const cardId = mockCatalogURL + 'author/Author/example'; + const commandService = getService('command-service'); + const command = new ListingCreateCommand( + commandService.commandContext, + ); + const result = await command.execute({ + openCardId: cardId, + codeRef: { + module: `${mockCatalogURL}author/author.gts`, + name: 'Author', + }, + targetRealm: mockCatalogURL, + }); + const interim = result?.listing as any; + assert.ok(interim, 'Interim listing exists'); + assert.strictEqual((interim as any).name, 'Mock Listing Title'); + assert.strictEqual( + (interim as any).summary, + 'Mock listing summary sentence.', + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${mockCatalogURL}index`, + }); + await verifySubmode(assert, 'code'); + const instanceFolder = 'CardListing/'; + await openDir(assert, instanceFolder); + const listingId = await verifyJSONWithUUIDInFolder( + assert, + instanceFolder, + ); + if (listingId) { + const listing = (await getService('store').get( + listingId, + )) as CardListing; + assert.ok(listing, 'Listing should be created'); + // Assertions for AI generated fields coming from proxy mock + assert.strictEqual( + (listing as any).name, + 'Mock Listing Title', + 'Listing name populated from autoPatchName mock response', + ); + assert.strictEqual( + (listing as any).summary, + 'Mock listing summary sentence.', + 'Listing summary populated from autoPatchSummary mock response', + ); + assert.strictEqual( + listing.specs.length, + 2, + 'Listing should have two specs', + ); + assert.true( + listing.specs.some((spec) => spec.ref.name === 'Author'), + 'Listing should have an Author spec', + ); + assert.true( + listing.specs.some((spec) => spec.ref.name === 'AuthorCompany'), + 'Listing should have an AuthorCompany spec', + ); + // Deterministic autoLink assertions from proxy mock + assert.ok((listing as any).license, 'License linked'); + assert.strictEqual( + (listing as any).license.id, + mitLicenseId, + 'License id matches mitLicenseId', + ); + assert.ok( + Array.isArray((listing as any).tags), + 'Tags array exists', + ); + assert.true( + (listing as any).tags.some( + (t: any) => t.id === calculatorTagId, + ), + 'Contains calculator tag id', + ); + assert.ok( + Array.isArray((listing as any).categories), + 'Categories array exists', + ); + assert.true( + (listing as any).categories.some( + (c: any) => c.id === writingCategoryId, + ), + 'Contains writing category id', + ); + } + }); + + test('listing will only create specs with recognised imports from realms it can read from', async function (assert) { + const cardId = mockCatalogURL + 'UnrecognisedImports/example'; + const commandService = getService('command-service'); + const command = new ListingCreateCommand( + commandService.commandContext, + ); + await command.execute({ + openCardId: cardId, + codeRef: { + module: `${mockCatalogURL}card-with-unrecognised-imports.gts`, + name: 'UnrecognisedImports', + }, + targetRealm: mockCatalogURL, + }); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${mockCatalogURL}index`, + }); + await verifySubmode(assert, 'code'); + const instanceFolder = 'CardListing/'; + await openDir(assert, instanceFolder); + const listingId = await verifyJSONWithUUIDInFolder( + assert, + instanceFolder, + ); + if (listingId) { + const listing = (await getService('store').get( + listingId, + )) as CardListing; + assert.ok(listing, 'Listing should be created'); + assert.true( + listing.specs.every( + (spec) => + spec.ref.module != + 'https://cdn.jsdelivr.net/npm/chess.js/+esm', + ), + 'Listing should does not have unrecognised import', + ); + } + }); + + test('app listing', async function (assert) { + const cardId = mockCatalogURL + 'blog-app/BlogApp/example'; + const commandService = getService('command-service'); + const command = new ListingCreateCommand( + commandService.commandContext, + ); + const createResult = await command.execute({ + openCardId: cardId, + codeRef: { + module: `${mockCatalogURL}blog-app/blog-app.gts`, + name: 'BlogApp', + }, + targetRealm: testDestinationRealmURL, + }); + // Assert store-level (in-memory) results BEFORE navigating to code mode + let immediateListing = createResult?.listing as any; + assert.ok(immediateListing, 'Listing object returned from command'); + assert.strictEqual( + immediateListing.name, + 'Mock Listing Title', + 'Immediate listing has patched name before persistence', + ); + assert.strictEqual( + immediateListing.summary, + 'Mock listing summary sentence.', + 'Immediate listing has patched summary before persistence', + ); + assert.ok( + immediateListing.license, + 'Immediate listing has linked license before persistence', + ); + assert.strictEqual( + immediateListing.license?.id, + mitLicenseId, + 'Immediate listing license id matches mitLicenseId', + ); + // Lint: avoid logical expression inside assertion + assert.ok( + Array.isArray(immediateListing.tags), + 'Immediate listing tags is an array before persistence', + ); + if (Array.isArray(immediateListing.tags)) { + assert.ok( + immediateListing.tags.length > 0, + 'Immediate listing has linked tag(s) before persistence', + ); + } + assert.true( + immediateListing.tags.some((t: any) => t.id === calculatorTagId), + 'Immediate listing includes calculator tag id', + ); + assert.ok( + Array.isArray(immediateListing.categories), + 'Immediate listing categories is an array before persistence', + ); + if (Array.isArray(immediateListing.categories)) { + assert.ok( + immediateListing.categories.length > 0, + 'Immediate listing has linked category(ies) before persistence', + ); + } + assert.true( + immediateListing.categories.some( + (c: any) => c.id === writingCategoryId, + ), + 'Immediate listing includes writing category id', + ); + assert.ok( + Array.isArray(immediateListing.specs), + 'Immediate listing specs is an array before persistence', + ); + if (Array.isArray(immediateListing.specs)) { + assert.strictEqual( + immediateListing.specs.length, + 5, + 'Immediate listing has expected number of specs before persistence', + ); + } + assert.ok( + Array.isArray(immediateListing.examples), + 'Immediate listing examples is an array before persistence', + ); + if (Array.isArray(immediateListing.examples)) { + assert.strictEqual( + immediateListing.examples.length, + 1, + 'Immediate listing has expected examples before persistence', + ); + } + // Header/title: wait for persisted id (listing.id) then assert via stack card selector + const persistedId = immediateListing.id; + assert.ok(persistedId, 'Immediate listing has a persisted id'); + await waitForCardOnStack(persistedId); + assert + .dom( + `[data-test-stack-card="${persistedId}"] [data-test-boxel-card-header-title]`, + ) + .containsText( + 'Mock Listing Title', + 'Isolated view shows patched name (persisted id)', + ); + // Summary section + assert + .dom('[data-test-catalog-listing-embedded-summary-section]') + .containsText( + 'Mock listing summary sentence.', + 'Isolated view shows patched summary', + ); + + // License section should not show fallback text + assert + .dom('[data-test-catalog-listing-embedded-license-section]') + .doesNotContainText( + 'No License Provided', + 'License section populated (autoLinkLicense)', + ); + + // Tags section + assert + .dom('[data-test-catalog-listing-embedded-tags-section]') + .doesNotContainText( + 'No Tags Provided', + 'Tags section populated (autoLinkTag)', + ); + + // Categories section + assert + .dom('[data-test-catalog-listing-embedded-categories-section]') + .doesNotContainText( + 'No Categories Provided', + 'Categories section populated (autoLinkCategory)', + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + await verifySubmode(assert, 'code'); + const instanceFolder = 'AppListing/'; + await openDir(assert, instanceFolder); + const persistedListingId = await verifyJSONWithUUIDInFolder( + assert, + instanceFolder, + ); + if (persistedListingId) { + const listing = (await getService('store').get( + persistedListingId, + )) as CardListing; + assert.ok(listing, 'Listing should be created'); + assert.strictEqual( + listing.specs.length, + 5, + 'Listing should have five specs', + ); + [ + 'Author', + 'AuthorCompany', + 'BlogPost', + 'BlogApp', + 'AppCard', + ].forEach((specName) => { + assert.true( + listing.specs.some((spec) => spec.ref.name === specName), + `Listing should have a ${specName} spec`, + ); + }); + assert.strictEqual( + listing.examples.length, + 1, + 'Listing should have one example', + ); + + // Assert autoPatch fields populated (from proxy mock responses) + assert.strictEqual( + (listing as any).name, + 'Mock Listing Title', + 'autoPatchName populated listing.name', + ); + assert.strictEqual( + (listing as any).summary, + 'Mock listing summary sentence.', + 'autoPatchSummary populated listing.summary', + ); + + // Basic object-level sanity for autoLink fields (they should exist, may be arrays) + assert.ok( + (listing as any).license, + 'autoLinkLicense populated listing.license', + ); + assert.strictEqual( + (listing as any).license?.id, + mitLicenseId, + 'Persisted listing license id matches mitLicenseId', + ); + assert.ok( + Array.isArray((listing as any).tags), + 'autoLinkTag populated listing.tags array', + ); + if (Array.isArray((listing as any).tags)) { + assert.ok( + (listing as any).tags.length > 0, + 'autoLinkTag populated listing.tags with at least one tag', + ); + } + assert.true( + (listing as any).tags.some( + (t: any) => t.id === calculatorTagId, + ), + 'Persisted listing includes calculator tag id', + ); + assert.ok( + Array.isArray((listing as any).categories), + 'autoLinkCategory populated listing.categories array', + ); + if (Array.isArray((listing as any).categories)) { + assert.ok( + (listing as any).categories.length > 0, + 'autoLinkCategory populated listing.categories with at least one category', + ); + } + assert.true( + (listing as any).categories.some( + (c: any) => c.id === writingCategoryId, + ), + 'Persisted listing includes writing category id', + ); + } + }); + + test('after create command, listing card opens on stack in interact mode', async function (assert) { + const cardId = mockCatalogURL + 'author/Author/example'; + const commandService = getService('command-service'); + const command = new ListingCreateCommand( + commandService.commandContext, + ); + + let r = await command.execute({ + openCardId: cardId, + codeRef: { + module: `${mockCatalogURL}author/author.gts`, + name: 'Author', + }, + targetRealm: mockCatalogURL, + }); + + await verifySubmode(assert, 'interact'); + const listing = r?.listing as any; + const createdId = listing.id; + assert.ok(createdId, 'Listing id should be present'); + await waitForCardOnStack(createdId); + assert + .dom(`[data-test-stack-card="${createdId}"]`) + .exists( + 'Created listing card (by persisted id) is displayed on stack after command execution', + ); + }); + }); + }); + }, + ); +} diff --git a/catalog-app-listing-install.test.gts b/catalog-app-listing-install.test.gts new file mode 100644 index 0000000..a08702a --- /dev/null +++ b/catalog-app-listing-install.test.gts @@ -0,0 +1,257 @@ +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { ensureTrailingSlash } from '@cardstack/runtime-common'; + +import ListingInstallCommand from '@cardstack/boxel-host/commands/listing-install'; + +import ENV from '@cardstack/host/config/environment'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; + +import { + setupLocalIndexing, + setupOnSave, + testRealmURL as mockCatalogURL, + setupAuthEndpoints, + setupUserSubscription, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + visitOperatorMode, + verifySubmode, + openDir, + verifyFolderWithUUIDInFileTree, + verifyFileInFileTree, + setCatalogRealmURL, +} from '@cardstack/host/tests/helpers'; +import { setupMockMatrix } from '@cardstack/host/tests/helpers/mock-matrix'; +import { setupApplicationTest } from '@cardstack/host/tests/helpers/setup'; + +import { + makeMockCatalogContents, + makeDestinationRealmContents, +} from './catalog-app-test-fixtures'; + +const catalogRealmURL = ensureTrailingSlash(ENV.resolvedCatalogRealmURL); +const testDestinationRealmURL = `http://test-realm/test2/`; + +//listing +const authorListingId = `${mockCatalogURL}Listing/author`; +const blogPostListingId = `${mockCatalogURL}Listing/blog-post`; + +export function runTests() { + module( + 'Acceptance | Catalog | catalog app - listing install', + function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [mockCatalogURL, testDestinationRealmURL], + }); + + let { createAndJoinRoom } = mockMatrixUtils; + + hooks.beforeEach(async function () { + createAndJoinRoom({ + sender: '@testuser:localhost', + name: 'room-test', + }); + setupUserSubscription(); + setupAuthEndpoints(); + setCatalogRealmURL(mockCatalogURL, catalogRealmURL); + // this setup test realm is pretending to be a mock catalog + await setupAcceptanceTestRealm({ + realmURL: mockCatalogURL, + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeMockCatalogContents(mockCatalogURL, catalogRealmURL), + }, + }); + await setupAcceptanceTestRealm({ + mockMatrixUtils, + realmURL: testDestinationRealmURL, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeDestinationRealmContents(), + }, + }); + }); + + async function executeCommand( + commandClass: typeof ListingInstallCommand, + listingUrl: string, + realm: string, + ) { + const commandService = getService('command-service'); + const store = getService('store'); + + const command = new commandClass(commandService.commandContext); + const listing = (await store.get(listingUrl)) as CardDef; + + return command.execute({ + realm, + listing, + }); + } + + module('listing commands', function (hooks) { + hooks.beforeEach(async function () { + // we always run a command inside interact mode + await visitOperatorMode({ + stacks: [[]], + }); + }); + module('"install"', function () { + test('card listing', async function (assert) { + const listingName = 'author'; + + await executeCommand( + ListingInstallCommand, + authorListingId, + testDestinationRealmURL, + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let gtsFilePath = `${outerFolder}${listingName}/author.gts`; + await openDir(assert, gtsFilePath); + await verifyFileInFileTree(assert, gtsFilePath); + let examplePath = `${outerFolder}${listingName}/Author/example.json`; + await openDir(assert, examplePath); + await verifyFileInFileTree(assert, examplePath); + }); + + test('listing installs relationships of examples and its modules', async function (assert) { + const listingName = 'blog-post'; + + await executeCommand( + ListingInstallCommand, + blogPostListingId, + testDestinationRealmURL, + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let blogPostModulePath = `${outerFolder}blog-post/blog-post.gts`; + let authorModulePath = `${outerFolder}author/author.gts`; + await openDir(assert, blogPostModulePath); + await verifyFileInFileTree(assert, blogPostModulePath); + await openDir(assert, authorModulePath); + await verifyFileInFileTree(assert, authorModulePath); + + let blogPostExamplePath = `${outerFolder}blog-post/BlogPost/example.json`; + let authorExamplePath = `${outerFolder}author/Author/example.json`; + let authorCompanyExamplePath = `${outerFolder}author/AuthorCompany/example.json`; + await openDir(assert, blogPostExamplePath); + await verifyFileInFileTree(assert, blogPostExamplePath); + await openDir(assert, authorExamplePath); + await verifyFileInFileTree(assert, authorExamplePath); + await openDir(assert, authorCompanyExamplePath); + await verifyFileInFileTree(assert, authorCompanyExamplePath); + }); + + test('field listing', async function (assert) { + const listingName = 'contact-link'; + const contactLinkFieldListingCardId = `${mockCatalogURL}FieldListing/contact-link`; + + await executeCommand( + ListingInstallCommand, + contactLinkFieldListingCardId, + testDestinationRealmURL, + ); + + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + // contact-link-[uuid]/ + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + await openDir(assert, `${outerFolder}fields/contact-link.gts`); + let gtsFilePath = `${outerFolder}fields/contact-link.gts`; + await verifyFileInFileTree(assert, gtsFilePath); + }); + + test('skill listing', async function (assert) { + const listingName = 'pirate-skill'; + const listingId = `${mockCatalogURL}SkillListing/${listingName}`; + await executeCommand( + ListingInstallCommand, + listingId, + testDestinationRealmURL, + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instancePath = `${outerFolder}Skill/pirate-speak.json`; + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + }); + }); + + test('"install" is successful even if target realm does not have a trailing slash', async function (assert) { + const listingName = 'author'; + await executeCommand( + ListingInstallCommand, + authorListingId, + removeTrailingSlash(testDestinationRealmURL), + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + + let gtsFilePath = `${outerFolder}${listingName}/author.gts`; + await openDir(assert, gtsFilePath); + await verifyFileInFileTree(assert, gtsFilePath); + let instancePath = `${outerFolder}${listingName}/Author/example.json`; + + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + }); + }); + }, + ); +} + +function removeTrailingSlash(url: string): string { + if (url === undefined || url === null) { + throw new Error(`removeTrailingSlash called with invalid url: ${url}`); + } + return url.endsWith('/') && url.length > 1 ? url.slice(0, -1) : url; +} diff --git a/catalog-app-listing-remix.test.gts b/catalog-app-listing-remix.test.gts new file mode 100644 index 0000000..a2fbd41 --- /dev/null +++ b/catalog-app-listing-remix.test.gts @@ -0,0 +1,234 @@ +import { waitFor, settled } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { ensureTrailingSlash } from '@cardstack/runtime-common'; + +import ListingRemixCommand from '@cardstack/boxel-host/commands/listing-remix'; + +import ENV from '@cardstack/host/config/environment'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; + +import { + setupLocalIndexing, + setupOnSave, + testRealmURL as mockCatalogURL, + setupAuthEndpoints, + setupUserSubscription, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + visitOperatorMode, + verifySubmode, + toggleFileTree, + openDir, + verifyFolderWithUUIDInFileTree, + verifyFileInFileTree, + setCatalogRealmURL, +} from '@cardstack/host/tests/helpers'; +import { setupMockMatrix } from '@cardstack/host/tests/helpers/mock-matrix'; +import { setupApplicationTest } from '@cardstack/host/tests/helpers/setup'; + +import { + makeMockCatalogContents, + makeDestinationRealmContents, +} from './catalog-app-test-fixtures'; + +const catalogRealmURL = ensureTrailingSlash(ENV.resolvedCatalogRealmURL); +const testDestinationRealmURL = `http://test-realm/test2/`; + +//listing +const themeListingId = `${mockCatalogURL}ThemeListing/cardstack-theme`; + +export function runTests() { + module( + 'Acceptance | Catalog | catalog app - listing remix', + function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [mockCatalogURL, testDestinationRealmURL], + }); + + let { createAndJoinRoom } = mockMatrixUtils; + + hooks.beforeEach(async function () { + createAndJoinRoom({ + sender: '@testuser:localhost', + name: 'room-test', + }); + setupUserSubscription(); + setupAuthEndpoints(); + setCatalogRealmURL(mockCatalogURL, catalogRealmURL); + // this setup test realm is pretending to be a mock catalog + await setupAcceptanceTestRealm({ + realmURL: mockCatalogURL, + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeMockCatalogContents(mockCatalogURL, catalogRealmURL), + }, + }); + await setupAcceptanceTestRealm({ + mockMatrixUtils, + realmURL: testDestinationRealmURL, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeDestinationRealmContents(), + }, + }); + }); + + async function executeCommand( + commandClass: typeof ListingRemixCommand, + listingUrl: string, + realm: string, + ) { + const commandService = getService('command-service'); + const store = getService('store'); + + const command = new commandClass(commandService.commandContext); + const listing = (await store.get(listingUrl)) as CardDef; + + return command.execute({ + realm, + listing, + }); + } + + module('listing commands', function (hooks) { + hooks.beforeEach(async function () { + // we always run a command inside interact mode + await visitOperatorMode({ + stacks: [[]], + }); + }); + module('"remix"', function () { + test('card listing: installs the card and redirects to code mode with persisted playground selection for first example successfully', async function (assert) { + const listingName = 'author'; + const listingId = `${mockCatalogURL}Listing/${listingName}`; + await visitOperatorMode({ + stacks: [[]], + }); + await executeCommand( + ListingRemixCommand, + listingId, + testDestinationRealmURL, + ); + await settled(); + await verifySubmode(assert, 'code'); + await toggleFileTree(); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instanceFile = `${outerFolder}${listingName}/Author/example.json`; + await openDir(assert, instanceFile); + await verifyFileInFileTree(assert, instanceFile); + let gtsFilePath = `${outerFolder}${listingName}/author.gts`; + await openDir(assert, gtsFilePath); + await verifyFileInFileTree(assert, gtsFilePath); + await settled(); + assert + .dom( + '[data-test-playground-panel] [data-test-boxel-card-header-title]', + ) + .hasText('Author - Mike Dane'); + }); + test('skill listing: installs the card and redirects to code mode with preview on first skill successfully', async function (assert) { + const listingName = 'pirate-skill'; + const listingId = `${mockCatalogURL}SkillListing/${listingName}`; + await executeCommand( + ListingRemixCommand, + listingId, + testDestinationRealmURL, + ); + await settled(); + await verifySubmode(assert, 'code'); + await toggleFileTree(); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instancePath = `${outerFolder}Skill/pirate-speak.json`; + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + let cardId = + testDestinationRealmURL + instancePath.replace('.json', ''); + await waitFor('[data-test-card-resource-loaded]'); + assert + .dom(`[data-test-code-mode-card-renderer-header="${cardId}"]`) + .exists(); + }); + test('theme listing: installs the theme example and redirects to code mode successfully', async function (assert) { + const listingName = 'cardstack-theme'; + await executeCommand( + ListingRemixCommand, + themeListingId, + testDestinationRealmURL, + ); + await settled(); + await verifySubmode(assert, 'code'); + await toggleFileTree(); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instancePath = `${outerFolder}theme/theme-example.json`; + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + let cardId = + testDestinationRealmURL + instancePath.replace('.json', ''); + await waitFor('[data-test-card-resource-loaded]'); + assert + .dom(`[data-test-code-mode-card-renderer-header="${cardId}"]`) + .exists(); + }); + }); + + test('"remix" is successful even if target realm does not have a trailing slash', async function (assert) { + const listingName = 'author'; + const listingId = `${mockCatalogURL}Listing/${listingName}`; + await visitOperatorMode({ + stacks: [[]], + }); + await executeCommand( + ListingRemixCommand, + listingId, + removeTrailingSlash(testDestinationRealmURL), + ); + await settled(); + await verifySubmode(assert, 'code'); + await toggleFileTree(); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instancePath = `${outerFolder}${listingName}/Author/example.json`; + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + let gtsFilePath = `${outerFolder}${listingName}/author.gts`; + await openDir(assert, gtsFilePath); + await verifyFileInFileTree(assert, gtsFilePath); + await settled(); + assert + .dom( + '[data-test-playground-panel] [data-test-boxel-card-header-title]', + ) + .hasText('Author - Mike Dane'); + }); + }); + }, + ); +} + +function removeTrailingSlash(url: string): string { + if (url === undefined || url === null) { + throw new Error(`removeTrailingSlash called with invalid url: ${url}`); + } + return url.endsWith('/') && url.length > 1 ? url.slice(0, -1) : url; +} diff --git a/catalog-app-listing-use.test.gts b/catalog-app-listing-use.test.gts new file mode 100644 index 0000000..1cc96e4 --- /dev/null +++ b/catalog-app-listing-use.test.gts @@ -0,0 +1,160 @@ +import { getService } from '@universal-ember/test-support'; +import { module, skip } from 'qunit'; + +import { ensureTrailingSlash } from '@cardstack/runtime-common'; + +import ListingUseCommand from '@cardstack/boxel-host/commands/listing-use'; + +import ENV from '@cardstack/host/config/environment'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; + +import { + setupLocalIndexing, + setupOnSave, + testRealmURL as mockCatalogURL, + setupAuthEndpoints, + setupUserSubscription, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + visitOperatorMode, + openDir, + verifyFolderWithUUIDInFileTree, + verifyJSONWithUUIDInFolder, + setCatalogRealmURL, +} from '@cardstack/host/tests/helpers'; +import { setupMockMatrix } from '@cardstack/host/tests/helpers/mock-matrix'; +import { setupApplicationTest } from '@cardstack/host/tests/helpers/setup'; + +import { + makeMockCatalogContents, + makeDestinationRealmContents, +} from './catalog-app-test-fixtures'; + +const catalogRealmURL = ensureTrailingSlash(ENV.resolvedCatalogRealmURL); +const testDestinationRealmURL = `http://test-realm/test2/`; + +export function runTests() { + module( + 'Acceptance | Catalog | catalog app - listing use', + function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [mockCatalogURL, testDestinationRealmURL], + }); + + let { createAndJoinRoom } = mockMatrixUtils; + + hooks.beforeEach(async function () { + createAndJoinRoom({ + sender: '@testuser:localhost', + name: 'room-test', + }); + setupUserSubscription(); + setupAuthEndpoints(); + setCatalogRealmURL(mockCatalogURL, catalogRealmURL); + // this setup test realm is pretending to be a mock catalog + await setupAcceptanceTestRealm({ + realmURL: mockCatalogURL, + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeMockCatalogContents(mockCatalogURL, catalogRealmURL), + }, + }); + await setupAcceptanceTestRealm({ + mockMatrixUtils, + realmURL: testDestinationRealmURL, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeDestinationRealmContents(), + }, + }); + }); + + async function executeCommand( + commandClass: typeof ListingUseCommand, + listingUrl: string, + realm: string, + ) { + const commandService = getService('command-service'); + const store = getService('store'); + + const command = new commandClass(commandService.commandContext); + const listing = (await store.get(listingUrl)) as CardDef; + + return command.execute({ + realm, + listing, + }); + } + + module('listing commands', function (hooks) { + hooks.beforeEach(async function () { + // we always run a command inside interact mode + await visitOperatorMode({ + stacks: [[]], + }); + }); + skip('"use"', async function () { + skip('card listing', async function (assert) { + const listingName = 'author'; + const listingId = mockCatalogURL + 'Listing/author.json'; + await executeCommand( + ListingUseCommand, + listingId, + testDestinationRealmURL, + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + + let instanceFolder = `${outerFolder}Author/`; + await openDir(assert, instanceFolder); + await verifyJSONWithUUIDInFolder(assert, instanceFolder); + }); + }); + + skip('"use" is successful even if target realm does not have a trailing slash', async function (assert) { + const listingName = 'author'; + const listingId = mockCatalogURL + 'Listing/author.json'; + await executeCommand( + ListingUseCommand, + listingId, + removeTrailingSlash(testDestinationRealmURL), + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + + let instanceFolder = `${outerFolder}Author`; + await openDir(assert, instanceFolder); + await verifyJSONWithUUIDInFolder(assert, instanceFolder); + }); + }); + }, + ); +} + +function removeTrailingSlash(url: string): string { + if (url === undefined || url === null) { + throw new Error(`removeTrailingSlash called with invalid url: ${url}`); + } + return url.endsWith('/') && url.length > 1 ? url.slice(0, -1) : url; +} diff --git a/catalog-app-test-fixtures.ts b/catalog-app-test-fixtures.ts new file mode 100644 index 0000000..3d0a974 --- /dev/null +++ b/catalog-app-test-fixtures.ts @@ -0,0 +1,721 @@ +export const authorCardSource = ` + import { field, contains, linksTo, CardDef } from 'https://cardstack.com/base/card-api'; + import StringField from 'https://cardstack.com/base/string'; + + + export class AuthorCompany extends CardDef { + static displayName = 'AuthorCompany'; + @field name = contains(StringField); + @field address = contains(StringField); + @field city = contains(StringField); + @field state = contains(StringField); + @field zip = contains(StringField); + } + + export class Author extends CardDef { + static displayName = 'Author'; + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Author) { + return [this.firstName, this.lastName].filter(Boolean).join(' '); + }, + }); + @field company = linksTo(AuthorCompany); + } +`; + +export const blogPostCardSource = ` + import { field, contains, CardDef, linksTo } from 'https://cardstack.com/base/card-api'; + import StringField from 'https://cardstack.com/base/string'; + import { Author } from '../author/author'; + + export class BlogPost extends CardDef { + static displayName = 'BlogPost'; + @field cardTitle = contains(StringField); + @field content = contains(StringField); + @field author = linksTo(Author); + } +`; + +export const contactLinkFieldSource = ` + import { field, contains, FieldDef } from 'https://cardstack.com/base/card-api'; + import StringField from 'https://cardstack.com/base/string'; + + export class ContactLink extends FieldDef { + static displayName = 'ContactLink'; + @field label = contains(StringField); + @field url = contains(StringField); + @field type = contains(StringField); + } +`; + +export const appCardSource = ` + import { CardDef } from 'https://cardstack.com/base/card-api'; + + export class AppCard extends CardDef { + static displayName = 'App Card'; + static prefersWideFormat = true; + } +`; + +export const blogAppCardSource = ` + import { field, contains, containsMany } from 'https://cardstack.com/base/card-api'; + import StringField from 'https://cardstack.com/base/string'; + import { AppCard } from '../app-card'; + import { BlogPost } from '../blog-post/blog-post'; + + export class BlogApp extends AppCard { + static displayName = 'Blog App'; + @field cardTitle = contains(StringField); + @field posts = containsMany(BlogPost); + } +`; + +export const cardWithUnrecognisedImports = ` + import { field, CardDef, linksTo } from 'https://cardstack.com/base/card-api'; + // External import that should be ignored by sanitizeDeps + import { Chess as _ChessJS } from 'https://cdn.jsdelivr.net/npm/chess.js/+esm'; + import { Author } from './author/author'; + + export class UnrecognisedImports extends CardDef { + static displayName = 'Unrecognised Imports'; + @field author = linksTo(Author); + } +`; + +export function makeMockCatalogContents( + mockCatalogURL: string, + catalogRealmURL: string, +): Record { + const authorCompanyExampleId = `${mockCatalogURL}author/AuthorCompany/example`; + const authorSpecId = `${mockCatalogURL}Spec/author`; + const authorExampleId = `${mockCatalogURL}author/Author/example`; + const calculatorTagId = `${mockCatalogURL}Tag/c1fe433a-b3df-41f4-bdcf-d98686ee42d7`; + const writingCategoryId = `${mockCatalogURL}Category/writing`; + const mitLicenseId = `${mockCatalogURL}License/mit`; + const publisherId = `${mockCatalogURL}Publisher/boxel-publisher`; + const pirateSkillId = `${mockCatalogURL}Skill/pirate-speak`; + const unknownSpecId = `${mockCatalogURL}Spec/unknown-no-type`; + const stubTagId = `${mockCatalogURL}Tag/stub`; + const authorListingId = `${mockCatalogURL}Listing/author`; + + return { + 'author/author.gts': authorCardSource, + 'blog-post/blog-post.gts': blogPostCardSource, + 'fields/contact-link.gts': contactLinkFieldSource, + 'app-card.gts': appCardSource, + 'blog-app/blog-app.gts': blogAppCardSource, + 'card-with-unrecognised-imports.gts': cardWithUnrecognisedImports, + 'theme/theme-example.json': { + data: { + type: 'card', + attributes: { + cssVariables: + ':root { --background: #ffffff; } .dark { --background: #000000; }', + cssImports: [], + cardInfo: { + cardTitle: 'Sample Theme', + cardDescription: 'A sample theme for testing remix.', + cardThumbnailURL: null, + notes: null, + }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'Theme', + }, + }, + }, + }, + 'ThemeListing/cardstack-theme.json': { + data: { + meta: { + adoptsFrom: { + name: 'ThemeListing', + module: `${catalogRealmURL}catalog-app/listing/listing`, + }, + }, + type: 'card', + attributes: { + name: 'Cardstack Theme', + images: [], + summary: 'Cardstack base theme listing.', + }, + relationships: { + specs: { + links: { + self: null, + }, + }, + skills: { + links: { + self: null, + }, + }, + tags: { + links: { + self: null, + }, + }, + license: { + links: { + self: null, + }, + }, + publisher: { + links: { + self: null, + }, + }, + 'examples.0': { + links: { + self: '../theme/theme-example', + }, + }, + categories: { + links: { + self: null, + }, + }, + }, + }, + }, + 'author/Author/example.json': { + data: { + type: 'card', + attributes: { + firstName: 'Mike', + lastName: 'Dane', + summary: 'Author', + }, + relationships: { + company: { + links: { + self: authorCompanyExampleId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${mockCatalogURL}author/author`, + name: 'Author', + }, + }, + }, + }, + 'author/AuthorCompany/example.json': { + data: { + type: 'card', + attributes: { + name: 'Cardstack Labs', + address: '123 Main St', + city: 'Portland', + state: 'OR', + zip: '97205', + }, + meta: { + adoptsFrom: { + module: `${mockCatalogURL}author/author`, + name: 'AuthorCompany', + }, + }, + }, + }, + 'UnrecognisedImports/example.json': { + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: `${mockCatalogURL}card-with-unrecognised-imports`, + name: 'UnrecognisedImports', + }, + }, + }, + }, + 'blog-post/BlogPost/example.json': { + data: { + type: 'card', + attributes: { + cardTitle: 'Blog Post', + content: 'Blog Post Content', + }, + relationships: { + author: { + links: { + self: authorExampleId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${mockCatalogURL}blog-post/blog-post`, + name: 'BlogPost', + }, + }, + }, + }, + 'blog-app/BlogApp/example.json': { + data: { + type: 'card', + attributes: { + cardTitle: 'My Blog App', + }, + meta: { + adoptsFrom: { + module: `${mockCatalogURL}blog-app/blog-app`, + name: 'BlogApp', + }, + }, + }, + }, + 'Spec/author.json': { + data: { + type: 'card', + attributes: { + readMe: 'This is the author spec readme', + ref: { + name: 'Author', + module: `${mockCatalogURL}author/author`, + }, + }, + specType: 'card', + containedExamples: [], + cardTitle: 'Author', + cardDescription: 'Spec for Author card', + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/spec', + name: 'Spec', + }, + }, + }, + }, + 'Spec/contact-link.json': { + data: { + type: 'card', + attributes: { + ref: { + name: 'ContactLink', + module: `${mockCatalogURL}fields/contact-link`, + }, + }, + specType: 'field', + containedExamples: [], + cardTitle: 'ContactLink', + cardDescription: 'Spec for ContactLink field', + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/spec', + name: 'Spec', + }, + }, + }, + }, + 'Spec/unknown-no-type.json': { + data: { + type: 'card', + attributes: { + readMe: 'Spec without specType to trigger unknown grouping', + ref: { + name: 'UnknownNoType', + module: `${mockCatalogURL}unknown/unknown-no-type`, + }, + }, + // intentionally omitting specType so it falls into 'unknown' + containedExamples: [], + cardTitle: 'UnknownNoType', + cardDescription: 'Spec lacking specType', + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/spec', + name: 'Spec', + }, + }, + }, + }, + 'Listing/author.json': { + data: { + type: 'card', + attributes: { + name: 'Author', + cardTitle: 'Author', // hardcoding title otherwise test will be flaky when waiting for a computed + summary: 'A card for representing an author.', + }, + relationships: { + 'specs.0': { + links: { + self: authorSpecId, + }, + }, + 'examples.0': { + links: { + self: authorExampleId, + }, + }, + 'tags.0': { + links: { + self: calculatorTagId, + }, + }, + 'categories.0': { + links: { + self: writingCategoryId, + }, + }, + license: { + links: { + self: mitLicenseId, + }, + }, + publisher: { + links: { + self: publisherId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'CardListing', + }, + }, + }, + }, + 'Listing/blog-post.json': { + data: { + type: 'card', + attributes: { + name: 'Blog Post', + cardTitle: 'Blog Post', + }, + relationships: { + 'examples.0': { + links: { + self: `${mockCatalogURL}blog-post/BlogPost/example`, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'CardListing', + }, + }, + }, + }, + 'Publisher/boxel-publisher.json': { + data: { + type: 'card', + attributes: { + name: 'Boxel Publishing', + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/publisher`, + name: 'Publisher', + }, + }, + }, + }, + 'License/mit.json': { + data: { + type: 'card', + attributes: { + name: 'MIT License', + content: 'MIT License', + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/license`, + name: 'License', + }, + }, + }, + }, + 'Listing/person.json': { + data: { + type: 'card', + attributes: { + name: 'Person', + cardTitle: 'Person', // hardcoding title otherwise test will be flaky when waiting for a computed + images: [ + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400', + 'https://images.unsplash.com/photo-1494790108755-2616b332db29?w=400', + 'https://images.unsplash.com/photo-1552374196-c4e7ffc6e126?w=400', + ], + }, + relationships: { + 'tags.0': { + links: { + self: calculatorTagId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'CardListing', + }, + }, + }, + }, + 'Listing/unknown-only.json': { + data: { + type: 'card', + attributes: {}, + relationships: { + 'specs.0': { + links: { + self: unknownSpecId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'CardListing', + }, + }, + }, + }, + 'AppListing/blog-app.json': { + data: { + type: 'card', + attributes: { + name: 'Blog App', + cardTitle: 'Blog App', // hardcoding title otherwise test will be flaky when waiting for a computed + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'AppListing', + }, + }, + }, + }, + 'Listing/empty.json': { + data: { + type: 'card', + attributes: { + name: 'Empty', + cardTitle: 'Empty', // hardcoding title otherwise test will be flaky when waiting for a computed + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'CardListing', + }, + }, + }, + }, + 'SkillListing/pirate-skill.json': { + data: { + type: 'card', + attributes: { + name: 'Pirate Skill', + cardTitle: 'Pirate Skill', // hardcoding title otherwise test will be flaky when waiting for a computed + }, + relationships: { + 'skills.0': { + links: { + self: pirateSkillId, + }, + }, + }, + 'categories.0': { + links: { + self: writingCategoryId, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'SkillListing', + }, + }, + }, + }, + 'Category/writing.json': { + data: { + type: 'card', + attributes: { + name: 'Writing', + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/category`, + name: 'Category', + }, + }, + }, + }, + 'Listing/incomplete-skill.json': { + data: { + type: 'card', + attributes: { + name: 'Incomplete Skill', + cardTitle: 'Incomplete Skill', // hardcoding title otherwise test will be flaky when waiting for a computed + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'SkillListing', + }, + }, + }, + }, + 'Skill/pirate-speak.json': { + data: { + type: 'card', + attributes: { + cardTitle: 'Talk Like a Pirate', + name: 'Pirate Speak', + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/skill', + name: 'Skill', + }, + }, + }, + }, + 'Tag/c1fe433a-b3df-41f4-bdcf-d98686ee42d7.json': { + data: { + type: 'card', + attributes: { + name: 'Calculator', + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/tag`, + name: 'Tag', + }, + }, + }, + }, + 'Tag/51de249c-516a-4c4d-bd88-76e88274c483.json': { + data: { + type: 'card', + attributes: { + name: 'Game', + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/tag`, + name: 'Tag', + }, + }, + }, + }, + 'Tag/stub.json': { + data: { + type: 'card', + attributes: { + name: 'Stub', + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/tag`, + name: 'Tag', + }, + }, + }, + }, + 'Listing/api-documentation-stub.json': { + data: { + type: 'card', + attributes: { + name: 'API Documentation', + cardTitle: 'API Documentation', // hardcoding title otherwise test will be flaky when waiting for a computed + }, + relationships: { + 'tags.0': { + links: { + self: stubTagId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'Listing', + }, + }, + }, + }, + 'FieldListing/contact-link.json': { + data: { + type: 'card', + attributes: { + name: 'Contact Link', + cardTitle: 'Contact Link', // hardcoding title otherwise test will be flaky when waiting for a computed + summary: + 'A field for creating and managing contact links such as email, phone, or other web links.', + }, + relationships: { + 'specs.0': { + links: { + self: `${mockCatalogURL}Spec/contact-link`, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'FieldListing', + }, + }, + }, + }, + 'index.json': { + data: { + type: 'card', + attributes: {}, + relationships: { + 'startHere.0': { + links: { + self: authorListingId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/catalog`, + name: 'Catalog', + }, + }, + }, + }, + '.realm.json': { + name: 'Cardstack Catalog', + backgroundURL: + 'https://i.postimg.cc/VNvHH93M/pawel-czerwinski-Ly-ZLa-A5jti-Y-unsplash.jpg', + iconURL: 'https://i.postimg.cc/L8yXRvws/icon.png', + }, + }; +} + +export function makeDestinationRealmContents(): Record { + return { + 'index.json': { + data: { + type: 'card', + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/cards-grid', + name: 'CardsGrid', + }, + }, + }, + }, + '.realm.json': { + name: 'Test Workspace B', + backgroundURL: + 'https://i.postimg.cc/VNvHH93M/pawel-czerwinski-Ly-ZLa-A5jti-Y-unsplash.jpg', + iconURL: 'https://i.postimg.cc/L8yXRvws/icon.png', + }, + }; +} diff --git a/commands/authed-fetch.ts b/commands/authed-fetch.ts new file mode 100644 index 0000000..eefa685 --- /dev/null +++ b/commands/authed-fetch.ts @@ -0,0 +1,52 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type NetworkService from '../services/network'; + +export default class AuthedFetchCommand extends HostBaseCommand< + typeof BaseCommandModule.AuthedFetchInput, + typeof BaseCommandModule.AuthedFetchResult +> { + @service declare private network: NetworkService; + + description = 'Perform an authenticated HTTP fetch'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { AuthedFetchInput } = commandModule; + return AuthedFetchInput; + } + + requireInputFields = ['url']; + + protected async run( + input: BaseCommandModule.AuthedFetchInput, + ): Promise { + let commandModule = await this.loadCommandModule(); + const { AuthedFetchResult } = commandModule; + const headers: Record = {}; + if (input.acceptHeader) { + headers['Accept'] = input.acceptHeader; + } + const response = await this.network.authedFetch(input.url, { + method: input.method ?? 'GET', + headers, + }); + let body: Record = {}; + if (response.ok) { + try { + body = await response.json(); + } catch { + // non-JSON response + } + } + return new AuthedFetchResult({ + ok: response.ok, + status: response.status, + body, + }); + } +} diff --git a/commands/can-read-realm.ts b/commands/can-read-realm.ts new file mode 100644 index 0000000..eb5bb7b --- /dev/null +++ b/commands/can-read-realm.ts @@ -0,0 +1,34 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type RealmService from '../services/realm'; + +export default class CanReadRealmCommand extends HostBaseCommand< + typeof BaseCommandModule.CanReadRealmInput, + typeof BaseCommandModule.CanReadRealmResult +> { + @service declare private realm: RealmService; + + description = 'Check whether the current user can read a realm'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { CanReadRealmInput } = commandModule; + return CanReadRealmInput; + } + + requireInputFields = ['realmUrl']; + + protected async run( + input: BaseCommandModule.CanReadRealmInput, + ): Promise { + let commandModule = await this.loadCommandModule(); + const { CanReadRealmResult } = commandModule; + return new CanReadRealmResult({ + canRead: this.realm.canRead(input.realmUrl), + }); + } +} diff --git a/commands/execute-atomic-operations.ts b/commands/execute-atomic-operations.ts new file mode 100644 index 0000000..fbc3ccb --- /dev/null +++ b/commands/execute-atomic-operations.ts @@ -0,0 +1,44 @@ +import { service } from '@ember/service'; + +import type { AtomicOperation } from '@cardstack/runtime-common/atomic-document'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type CardService from '../services/card-service'; + +export default class ExecuteAtomicOperationsCommand extends HostBaseCommand< + typeof BaseCommandModule.ExecuteAtomicOperationsInput, + typeof BaseCommandModule.ExecuteAtomicOperationsResult +> { + @service declare private cardService: CardService; + + description = 'Execute atomic operations against a realm'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { ExecuteAtomicOperationsInput } = commandModule; + return ExecuteAtomicOperationsInput; + } + + requireInputFields = ['realmUrl', 'operations']; + + protected async run( + input: BaseCommandModule.ExecuteAtomicOperationsInput, + ): Promise { + let commandModule = await this.loadCommandModule(); + const { ExecuteAtomicOperationsResult } = commandModule; + const results = await this.cardService.executeAtomicOperations( + input.operations as AtomicOperation[], + new URL(input.realmUrl), + ); + const atomicResults = results['atomic:results']; + if (!Array.isArray(atomicResults)) { + const detail = (results as { errors?: Array<{ detail?: string }> }) + .errors?.[0]?.detail; + throw new Error(detail ?? 'Atomic operations failed'); + } + return new ExecuteAtomicOperationsResult({ results: atomicResults }); + } +} diff --git a/commands/fetch-card-json.ts b/commands/fetch-card-json.ts new file mode 100644 index 0000000..0742b36 --- /dev/null +++ b/commands/fetch-card-json.ts @@ -0,0 +1,33 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type CardService from '../services/card-service'; + +export default class FetchCardJsonCommand extends HostBaseCommand< + typeof BaseCommandModule.FetchCardJsonInput, + typeof BaseCommandModule.FetchCardJsonResult +> { + @service declare private cardService: CardService; + + description = 'Fetch a card as a JSON document by URL'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { FetchCardJsonInput } = commandModule; + return FetchCardJsonInput; + } + + requireInputFields = ['url']; + + protected async run( + input: BaseCommandModule.FetchCardJsonInput, + ): Promise { + let commandModule = await this.loadCommandModule(); + const { FetchCardJsonResult } = commandModule; + const doc = await this.cardService.fetchJSON(input.url); + return new FetchCardJsonResult({ document: doc }); + } +} diff --git a/commands/get-available-realm-urls.ts b/commands/get-available-realm-urls.ts new file mode 100644 index 0000000..8ea44b3 --- /dev/null +++ b/commands/get-available-realm-urls.ts @@ -0,0 +1,29 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type RealmServerService from '../services/realm-server'; + +export default class GetAvailableRealmUrlsCommand extends HostBaseCommand< + undefined, + typeof BaseCommandModule.GetAvailableRealmUrlsResult +> { + @service declare private realmServer: RealmServerService; + + static actionVerb = 'Get Realm URLs'; + description = 'Get the list of available realm URLs'; + + async getInputType() { + return undefined; + } + + protected async run(): Promise { + let commandModule = await this.loadCommandModule(); + const { GetAvailableRealmUrlsResult } = commandModule; + return new GetAvailableRealmUrlsResult({ + urls: this.realmServer.availableRealmURLs, + }); + } +} diff --git a/commands/get-catalog-realm-urls.ts b/commands/get-catalog-realm-urls.ts new file mode 100644 index 0000000..1816f41 --- /dev/null +++ b/commands/get-catalog-realm-urls.ts @@ -0,0 +1,29 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type RealmServerService from '../services/realm-server'; + +export default class GetCatalogRealmUrlsCommand extends HostBaseCommand< + undefined, + typeof BaseCommandModule.GetCatalogRealmUrlsResult +> { + @service declare private realmServer: RealmServerService; + + static actionVerb = 'Get Catalog Realm URLs'; + description = 'Get the list of catalog realm URLs'; + + async getInputType() { + return undefined; + } + + protected async run(): Promise { + let commandModule = await this.loadCommandModule(); + const { GetCatalogRealmUrlsResult } = commandModule; + return new GetCatalogRealmUrlsResult({ + urls: this.realmServer.catalogRealmURLs, + }); + } +} diff --git a/commands/get-default-writable-realm.ts b/commands/get-default-writable-realm.ts new file mode 100644 index 0000000..8dd8ba4 --- /dev/null +++ b/commands/get-default-writable-realm.ts @@ -0,0 +1,28 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type RealmService from '../services/realm'; + +export default class GetDefaultWritableRealmCommand extends HostBaseCommand< + undefined, + typeof BaseCommandModule.GetDefaultWritableRealmResult +> { + @service declare private realm: RealmService; + + description = 'Get the path of the default writable realm'; + + async getInputType() { + return undefined; + } + + protected async run(): Promise { + let commandModule = await this.loadCommandModule(); + const { GetDefaultWritableRealmResult } = commandModule; + return new GetDefaultWritableRealmResult({ + realmPath: this.realm.defaultWritableRealm?.path ?? '', + }); + } +} diff --git a/commands/get-realm-of-url.ts b/commands/get-realm-of-url.ts new file mode 100644 index 0000000..b5a1dc4 --- /dev/null +++ b/commands/get-realm-of-url.ts @@ -0,0 +1,33 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type RealmService from '../services/realm'; + +export default class GetRealmOfUrlCommand extends HostBaseCommand< + typeof BaseCommandModule.GetRealmOfUrlInput, + typeof BaseCommandModule.GetRealmOfUrlResult +> { + @service declare private realm: RealmService; + + description = 'Get the realm URL that contains a given URL'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { GetRealmOfUrlInput } = commandModule; + return GetRealmOfUrlInput; + } + + requireInputFields = ['url']; + + protected async run( + input: BaseCommandModule.GetRealmOfUrlInput, + ): Promise { + let commandModule = await this.loadCommandModule(); + const { GetRealmOfUrlResult } = commandModule; + const realmUrl = this.realm.realmOfURL(new URL(input.url)); + return new GetRealmOfUrlResult({ realmUrl: realmUrl?.href ?? '' }); + } +} diff --git a/commands/listing-action-build.ts b/commands/listing-action-build.ts new file mode 100644 index 0000000..9eb0d75 --- /dev/null +++ b/commands/listing-action-build.ts @@ -0,0 +1,79 @@ +import { DEFAULT_CODING_LLM } from '@cardstack/runtime-common/matrix-constants'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; +import { devSkillId, skillCardURL } from '../lib/utils'; + +import CreateAiAssistantRoomCommand from './create-ai-assistant-room'; +import OpenAiAssistantRoomCommand from './open-ai-assistant-room'; +import SendAiAssistantMessageCommand from './send-ai-assistant-message'; +import SetActiveLLMCommand from './set-active-llm'; +import SwitchSubmodeCommand from './switch-submode'; +import UpdateRoomSkillsCommand from './update-room-skills'; + +import type { Listing } from '@cardstack/catalog/catalog-app/listing/listing'; + +export default class ListingActionBuildCommand extends HostBaseCommand< + typeof BaseCommandModule.ListingBuildInput +> { + description = 'Catalog listing build command'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { ListingBuildInput } = commandModule; + return ListingBuildInput; + } + + requireInputFields = ['realm', 'listing']; + + protected async run( + input: BaseCommandModule.ListingBuildInput, + ): Promise { + let { realm: realmUrl, listing: listingInput } = input; + + const listing = listingInput as Listing; + + const prompt = `Generate .gts card definition for "${listing.name}" implementing all requirements from the attached listing specification. Then preview the final code in playground panel.`; + + const { roomId } = await new CreateAiAssistantRoomCommand( + this.commandContext, + ).execute({ + name: `Build ${listing.name}`, + }); + + const defaultSkills = [ + devSkillId, + skillCardURL('catalog-listing'), + skillCardURL('source-code-editing'), + ]; + + if (roomId) { + await new SetActiveLLMCommand(this.commandContext).execute({ + roomId, + model: DEFAULT_CODING_LLM, + mode: 'act', + }); + + await new UpdateRoomSkillsCommand(this.commandContext).execute({ + roomId, + skillCardIdsToActivate: defaultSkills, + }); + + await new SwitchSubmodeCommand(this.commandContext).execute({ + submode: 'code', + codePath: `${realmUrl}index.json`, + }); + + await new SendAiAssistantMessageCommand(this.commandContext).execute({ + roomId, + prompt, + attachedCards: [listing], + }); + + await new OpenAiAssistantRoomCommand(this.commandContext).execute({ + roomId, + }); + } + } +} diff --git a/commands/listing-action-init.ts b/commands/listing-action-init.ts new file mode 100644 index 0000000..ec57c7d --- /dev/null +++ b/commands/listing-action-init.ts @@ -0,0 +1,106 @@ +import { DEFAULT_REMIX_LLM } from '@cardstack/runtime-common/matrix-constants'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; +import { skillCardURL } from '../lib/utils'; + +import CreateAiAssistantRoomCommand from './create-ai-assistant-room'; +import OpenAiAssistantRoomCommand from './open-ai-assistant-room'; +import SendAiAssistantMessageCommand from './send-ai-assistant-message'; +import SetActiveLLMCommand from './set-active-llm'; +import UpdateRoomSkillsCommand from './update-room-skills'; + +import type { Listing } from '@cardstack/catalog/catalog-app/listing/listing'; + +export default class ListingActionInitCommand extends HostBaseCommand< + typeof BaseCommandModule.ListingActionInput +> { + description = 'Catalog listing use command'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { ListingActionInput } = commandModule; + return ListingActionInput; + } + + requireInputFields = ['actionType', 'listing']; + + protected async run( + input: BaseCommandModule.ListingActionInput, + ): Promise { + let { + realm: realmUrl, + actionType, + listing: listingInput, + attachedCard, + } = input; + + const listing = listingInput as Listing; + + let roomName = ''; + switch (actionType) { + case 'remix': + roomName = `Remix of ${listing.name}`; + break; + case 'use': + roomName = `Use of ${listing.name}`; + break; + case 'install': + roomName = `Install of ${listing.name}`; + break; + case 'create': + roomName = `Create Listing`; + break; + default: + throw new Error(`Invalid listing action type: ${actionType}`); + } + + const { roomId } = await new CreateAiAssistantRoomCommand( + this.commandContext, + ).execute({ + name: roomName, + }); + + const listingSkillCardId = skillCardURL('catalog-listing'); + + if (listingSkillCardId) { + await new UpdateRoomSkillsCommand(this.commandContext).execute({ + roomId, + skillCardIdsToActivate: [listingSkillCardId], + }); + } + + let prompt = `I would like to create a new listing`; + if (actionType !== 'create') { + prompt = `I would like to ${actionType} this ${listing.name} under the following realm: ${realmUrl}`; + } + + let openCardIds: string[] = []; + if (actionType === 'create') { + openCardIds = [attachedCard.id!]; + } else { + openCardIds = [listing.id!]; + } + + if (roomId) { + let setActiveLLMCommand = new SetActiveLLMCommand(this.commandContext); + + await setActiveLLMCommand.execute({ + roomId, + model: DEFAULT_REMIX_LLM, + }); + + await new SendAiAssistantMessageCommand(this.commandContext).execute({ + roomId, + prompt, + openCardIds, + attachedCards: actionType === 'create' ? [attachedCard] : [listing], + }); + + await new OpenAiAssistantRoomCommand(this.commandContext).execute({ + roomId, + }); + } + } +} diff --git a/commands/listing-create.ts b/commands/listing-create.ts new file mode 100644 index 0000000..3c721af --- /dev/null +++ b/commands/listing-create.ts @@ -0,0 +1,615 @@ +import { isScopedCSSRequest } from 'glimmer-scoped-css'; + +import type { + LooseSingleCardDocument, + ResolvedCodeRef, +} from '@cardstack/runtime-common'; +import { + isCardInstance, + SupportedMimeType, + isFieldDef, + isResolvedCodeRef, + trimExecutableExtension, +} from '@cardstack/runtime-common'; +import { + loadCardDef, + getAncestor, + identifyCard, +} from '@cardstack/runtime-common/code-ref'; + +import type * as CardAPI from 'https://cardstack.com/base/card-api'; +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import type { Spec } from 'https://cardstack.com/base/spec'; + +import HostBaseCommand from '../lib/host-base-command'; + +import AuthedFetchCommand from './authed-fetch'; +import CanReadRealmCommand from './can-read-realm'; +import CreateSpecCommand from './create-specs'; +import GetCatalogRealmUrlsCommand from './get-catalog-realm-urls'; +import GetCardCommand from './get-card'; +import GetRealmOfUrlCommand from './get-realm-of-url'; +import OneShotLlmRequestCommand from './one-shot-llm-request'; +import SearchAndChooseCommand from './search-and-choose'; +import { SearchCardsByTypeAndTitleCommand } from './search-cards'; +import StoreAddCommand from './store-add'; + +type ListingType = 'card' | 'skill' | 'theme' | 'field'; + +const BASE_CARD_API_MODULE = 'https://cardstack.com/base/card-api'; +const BASE_SKILL_MODULE = 'https://cardstack.com/base/skill'; + +const listingSubClass: Record = { + card: 'CardListing', + skill: 'SkillListing', + theme: 'ThemeListing', + field: 'FieldListing', +}; + +export default class ListingCreateCommand extends HostBaseCommand< + typeof BaseCommandModule.ListingCreateInput, + typeof BaseCommandModule.ListingCreateResult +> { + static actionVerb = 'Create'; + description = 'Create a catalog listing for an example card'; + + private async getCatalogRealm(): Promise { + const { urls } = await new GetCatalogRealmUrlsCommand( + this.commandContext, + ).execute(undefined); + return urls.find((realm: string) => realm.endsWith('/catalog/')); + } + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { ListingCreateInput } = commandModule; + return ListingCreateInput; + } + + requireInputFields = ['codeRef', 'targetRealm']; + + private async sanitizeModuleList( + modulesToCreate: Iterable, + ): Promise { + // Normalize to extensionless URLs before deduplication so that e.g. + // "https://…/foo.gts" and "https://…/foo" don't produce separate entries. + const seen = new Map(); // normalized → original + for (const m of modulesToCreate) { + const normalized = trimExecutableExtension(new URL(m)).href; + if (!seen.has(normalized)) { + seen.set(normalized, m); + } + } + let uniqueModules = Array.from(seen.values()); + + const results = await Promise.all( + uniqueModules.map(async (dep) => { + // Exclude scoped CSS requests + if (isScopedCSSRequest(dep)) { + return null; + } + // Exclude known global/package/icon sources + if ( + [ + 'https://cardstack.com', + 'https://packages', + 'https://boxel-icons.boxel.ai', + ].some((urlStem) => dep.startsWith(urlStem)) + ) { + return null; + } + + // Only allow modulesToCreate that belong to a realm we can read + const { realmUrl } = await new GetRealmOfUrlCommand( + this.commandContext, + ).execute({ url: dep }); + if (!realmUrl) { + return null; + } + const { canRead } = await new CanReadRealmCommand( + this.commandContext, + ).execute({ realmUrl }); + return canRead ? dep : null; + }), + ); + + return results.filter((dep): dep is string => dep !== null); + } + + protected async run( + input: BaseCommandModule.ListingCreateInput, + ): Promise { + let { openCardIds, codeRef, targetRealm } = input; + + if (!codeRef) { + throw new Error('codeRef is required'); + } + if (!isResolvedCodeRef(codeRef)) { + throw new Error('codeRef must be a ResolvedCodeRef with module and name'); + } + if (!targetRealm) { + throw new Error('Target Realm is required'); + } + + let listingType = await this.guessListingType(codeRef); + const catalogRealm = await this.getCatalogRealm(); + + let relationships: Record = {}; + if (openCardIds && openCardIds.length > 0) { + openCardIds.forEach((id, index) => { + relationships[`examples.${index}`] = { links: { self: id } }; + }); + } + + const listingDoc: LooseSingleCardDocument = { + data: { + type: 'card', + relationships, + meta: { + adoptsFrom: { + module: `${catalogRealm}catalog-app/listing/listing`, + name: listingSubClass[listingType], + }, + }, + }, + }; + const listing = await new StoreAddCommand(this.commandContext).execute({ + document: listingDoc, + realm: targetRealm, + }); + + const commandModule = await this.loadCommandModule(); + const listingCard = listing as CardAPI.CardDef; + const firstOpenCardId = openCardIds?.[0]; + + const backgroundWork = Promise.all([ + this.autoPatchName(listingCard, codeRef), + this.autoPatchSummary(listingCard, codeRef), + this.autoLinkTag(listingCard, codeRef), + this.autoLinkCategory(listingCard, codeRef), + this.autoLinkLicense(listingCard), + this.autoLinkExample(listingCard, codeRef, openCardIds), + this.linkSpecs( + listingCard, + targetRealm, + firstOpenCardId ?? codeRef?.module, + codeRef.module, + codeRef, + ), + ]).catch((error) => { + console.warn('Background autopatch failed:', error); + }); + + const { ListingCreateResult } = commandModule; + const result = new ListingCreateResult({ listing }); + (result as any).backgroundWork = backgroundWork; + return result; + } + + private async guessListingType( + codeRef: ResolvedCodeRef, + ): Promise { + let cardDef; + try { + cardDef = await loadCardDef(codeRef, { + loader: this.loaderService.loader, + }); + } catch { + return 'card'; + } + + if (isFieldDef(cardDef)) { + return 'field'; + } + if (this.isAncestor(cardDef, BASE_CARD_API_MODULE, 'Theme')) { + return 'theme'; + } + if (this.isAncestor(cardDef, BASE_SKILL_MODULE, 'Skill')) { + return 'skill'; + } + return 'card'; + } + + private isAncestor( + cardDef: CardAPI.BaseDefConstructor, + targetModule: string, + targetName: string, + ): boolean { + let current: CardAPI.BaseDefConstructor | undefined = cardDef; + while (current) { + const ref = identifyCard(current); + if ( + ref && + !('type' in ref) && + ref.module === targetModule && + ref.name === targetName + ) { + return true; + } + current = getAncestor(current) ?? undefined; + } + return false; + } + + private async linkSpecs( + listing: CardAPI.CardDef, + targetRealm: string, + resourceUrl: string, // can be module or card instance id + moduleUrl: string, // the module URL of the card type being listed + codeRef: ResolvedCodeRef, // the specific export being listed + ): Promise { + const { realmUrl: resourceRealmUrl } = await new GetRealmOfUrlCommand( + this.commandContext, + ).execute({ url: resourceUrl }); + const resourceRealm = resourceRealmUrl || targetRealm; + const depUrl = `${resourceRealm}_dependencies?url=${encodeURIComponent(resourceUrl)}`; + const { ok, body: jsonApiResponse } = await new AuthedFetchCommand( + this.commandContext, + ).execute({ url: depUrl, acceptHeader: SupportedMimeType.JSONAPI }); + + if (!ok) { + console.warn('Failed to fetch dependencies for specs'); + (listing as any).specs = []; + return []; + } + + // Collect all modules (main + dependencies). Deduplication happens in sanitizeModuleList(). + // The _dependencies endpoint excludes the queried resource itself, so we + // explicitly include the module URL to ensure a spec is created for it. + const modulesToCreate: string[] = [moduleUrl]; + + ( + jsonApiResponse as { + data?: Array<{ + attributes?: { dependencies?: string[] }; + }>; + } + ).data?.forEach((entry) => { + if (entry.attributes?.dependencies) { + modulesToCreate.push(...entry.attributes.dependencies); + } + }); + + const sanitizedModules = await this.sanitizeModuleList(modulesToCreate); + + // Create specs for all unique modules + const uniqueSpecsById = new Map(); + + if (sanitizedModules.length > 0) { + const createSpecCommand = new CreateSpecCommand(this.commandContext); + const normalizedModuleUrl = trimExecutableExtension( + new URL(moduleUrl), + ).href; + const specResults = await Promise.all( + sanitizedModules.map((module) => { + // For the main module, use the specific codeRef (with export name) so + // only the listed export gets a spec, not every export in the file. + // Normalize both sides before comparing — _dependencies can return + // URLs with executable extensions (e.g. .gts) while moduleUrl/codeRef.module + // is often extensionless, so a bare string comparison would create + // duplicate specs for the same source file. + const normalizedModule = trimExecutableExtension( + new URL(module), + ).href; + const input = + normalizedModule === normalizedModuleUrl + ? { codeRef, targetRealm, autoGenerateReadme: true } + : { module, targetRealm, autoGenerateReadme: true }; + return createSpecCommand.execute(input).catch((e: unknown) => { + console.warn('Failed to create spec(s) for', module, e); + return undefined; + }); + }), + ); + + specResults.forEach((result) => { + result?.specs?.forEach((spec) => { + if (spec?.id) { + uniqueSpecsById.set(spec.id, spec); + } + }); + }); + } + + const specs = Array.from(uniqueSpecsById.values()); + (listing as any).specs = specs; + return specs; + } + + // --- Linking helpers now use SearchAndChooseCommand --- + private async chooseCards( + params: { + candidateTypeCodeRef: ResolvedCodeRef; + sourceContextCodeRef?: ResolvedCodeRef; + }, + opts?: { + max?: number; + additionalSystemPrompt?: string; + }, + ) { + const command = new SearchAndChooseCommand(this.commandContext); + const result = await command.execute({ + candidateTypeCodeRef: params.candidateTypeCodeRef, + sourceContextCodeRef: params.sourceContextCodeRef, + max: opts?.max, + additionalSystemPrompt: opts?.additionalSystemPrompt, + }); + return result.selectedCards ?? []; + } + + private async autoPatchName( + listing: CardAPI.CardDef, + codeRef: ResolvedCodeRef, + ) { + const name = await this.getStringPatch({ + codeRef, + systemPrompt: + 'You are a concise and accurate summarization system. You read a Cardstack card/field definition source file and create a concise catalog listing title. Respond ONLY with the title text—no quotes, no JSON, no markdown, and no extra commentary.', + userPrompt: [ + `Generate a catalog listing title for the definition referenced by:`, + `- module: ${codeRef.module}`, + `- exportName: ${codeRef.name}`, + `Use ONLY the attached module source shown below (the file content).`, + `Focus on the export named "${codeRef.name}" (ignore other exports).`, + ].join('\n'), + }); + if (name) { + (listing as any).name = name; + } + } + + private async autoPatchSummary( + listing: CardAPI.CardDef, + codeRef: ResolvedCodeRef, + ) { + const summary = await this.getStringPatch({ + codeRef, + systemPrompt: + 'You are a concise and accurate summarization system. You read a Cardstack card/field definition source file and write a concise spec-style summary. Output ONLY the summary text—no quotes, no JSON, no markdown, and no extra commentary.', + userPrompt: [ + `Generate a README-style catalog listing summary for the definition referenced by:`, + `- module: ${codeRef.module}`, + `- exportName: ${codeRef.name}`, + `Use ONLY the attached module source shown below (the file content).`, + `Focus on the export named "${codeRef.name}" (ignore other exports).`, + `Focus on what this listing (app/card/field/skill/theme) does and its primary purpose. Avoid implementation details and marketing fluff.`, + ].join('\n'), + }); + if (summary) { + (listing as any).summary = summary; + } + } + + private async autoLinkExample( + listing: CardAPI.CardDef, + codeRef: ResolvedCodeRef, + openCardIds?: string[], + ) { + const existingExamples = Array.isArray((listing as any).examples) + ? ((listing as any).examples as CardAPI.CardDef[]) + : []; + const uniqueById = new Map(); + const addCard = (card?: CardAPI.CardDef) => { + if (!card || typeof card.id !== 'string') { + return; + } + if (uniqueById.has(card.id)) { + return; + } + uniqueById.set(card.id, card); + }; + + for (const existing of existingExamples) { + addCard(existing); + } + + if (openCardIds && openCardIds.length > 0) { + await Promise.all( + openCardIds.map(async (openCardId) => { + try { + const instance = await new GetCardCommand( + this.commandContext, + ).execute({ cardId: openCardId }); + if (isCardInstance(instance)) { + addCard(instance as CardAPI.CardDef); + } else { + console.warn( + 'autoLinkExample: openCardId is not a card instance', + { openCardId }, + ); + } + } catch (error) { + console.warn('autoLinkExample: failed to load openCardId', { + openCardId, + error, + }); + } + }), + ); + } else { + // If no openCardIds were provided, attempt to find any existing instance of this type. + try { + const search = new SearchCardsByTypeAndTitleCommand( + this.commandContext, + ); + const result = await search.execute({ type: codeRef }); + const instances = (result as any)?.instances as unknown; + if (Array.isArray(instances)) { + const first = instances.find( + (c: any) => c && typeof c.id === 'string' && isCardInstance(c), + ); + if (first) { + addCard(first as CardAPI.CardDef); + } + } + } catch (error) { + console.warn( + 'autoLinkExample: failed to search for an example instance', + { codeRef, error }, + ); + } + } + + // Only auto-fill additional examples when the user didn't explicitly choose + const userExplicitlyChose = openCardIds && openCardIds.length > 0; + const MAX_EXAMPLES = 4; + if ( + !userExplicitlyChose && + codeRef && + uniqueById.size > 0 && + uniqueById.size < MAX_EXAMPLES + ) { + try { + const existingIds = Array.from(uniqueById.keys()); + const additionalExamples = await this.chooseCards( + { + candidateTypeCodeRef: codeRef, + }, + { + max: Math.max(1, MAX_EXAMPLES - existingIds.length), + additionalSystemPrompt: [ + 'Prefer examples that showcase common or high-impact use cases.', + existingIds.length + ? `Do not include any id already linked: ${existingIds.join(', ')}.` + : '', + 'Return [] if nothing relevant is found.', + ] + .filter(Boolean) + .join(' '), + }, + ); + for (const card of additionalExamples) { + addCard(card as CardAPI.CardDef); + } + } catch (error) { + console.warn('Failed to auto-link additional examples', { + codeRef, + error, + }); + } + } + (listing as any).examples = Array.from(uniqueById.values()); + } + + private async autoLinkLicense(listing: CardAPI.CardDef) { + const catalogRealm = await this.getCatalogRealm(); + const selected = await this.chooseCards({ + candidateTypeCodeRef: { + module: `${catalogRealm}catalog-app/listing/license`, + name: 'License', + } as ResolvedCodeRef, + }); + (listing as any).license = selected[0]; + } + + private async autoLinkTag( + listing: CardAPI.CardDef, + codeRef: ResolvedCodeRef, + ) { + const catalogRealm = await this.getCatalogRealm(); + const selected = await this.chooseCards( + { + candidateTypeCodeRef: { + module: `${catalogRealm}catalog-app/listing/tag`, + name: 'Tag', + } as ResolvedCodeRef, + sourceContextCodeRef: codeRef, + }, + { + max: 1, + additionalSystemPrompt: + 'You are selecting from an existing list of catalog tags. ' + + "Choose the single best tag that describes the card's subject matter, use case, or domain. " + + 'Prefer a specific descriptive tag over a broad organizational bucket. ' + + 'Only select ids from the provided options. ' + + 'Return [] if no tag clearly fits.', + }, + ); + (listing as any).tags = selected; + } + + private async autoLinkCategory( + listing: CardAPI.CardDef, + codeRef: ResolvedCodeRef, + ) { + const catalogRealm = await this.getCatalogRealm(); + const selected = await this.chooseCards( + { + candidateTypeCodeRef: { + module: `${catalogRealm}catalog-app/listing/category`, + name: 'Category', + } as ResolvedCodeRef, + sourceContextCodeRef: codeRef, + }, + { + max: 1, + additionalSystemPrompt: + 'You are selecting from an existing list of catalog categories. ' + + "Choose the single best high-level category that matches the card's main purpose. " + + 'Prefer broad organizing categories over keyword-style tags. ' + + 'Only select ids from the provided options. ' + + 'Return [] if no category clearly fits.', + }, + ); + (listing as any).categories = selected; + } + + private async getStringPatch(opts: { + systemPrompt: string; + userPrompt: string; + codeRef?: ResolvedCodeRef; + }): Promise { + const { systemPrompt, userPrompt, codeRef } = opts; + const oneShot = new OneShotLlmRequestCommand(this.commandContext); + const result = await oneShot.execute({ + ...(codeRef ? { codeRef } : {}), + systemPrompt, + userPrompt, + llmModel: 'openai/gpt-4.1-nano', + }); + if (!result.output) return undefined; + // We don't strictly need the key now; keep signature for compatibility. + return parseResponseToString(result.output); + } +} + +// Parse an AI response into a concise string (e.g. title/summary first line) +// - Strips markdown code fences +// - If JSON with a single string property, returns that value +// - Falls back to first non-empty line +// - Truncates to maxLength +function parseResponseToString( + response?: string, + maxLength: number = 1000, +): string | undefined { + if (!response) return undefined; + let text = response.trim(); + if (!text) return undefined; + if (text.startsWith('{') || text.startsWith('[')) { + try { + const parsed = JSON.parse(text); + if (typeof parsed === 'string') { + return parsed.slice(0, maxLength); + } + if (Array.isArray(parsed)) { + const first = parsed.find((v) => typeof v === 'string'); + if (first) return first.slice(0, maxLength); + return undefined; + } + if (parsed && typeof parsed === 'object') { + for (const v of Object.values(parsed as any)) { + if (typeof v === 'string' && v.trim()) { + return v.trim().slice(0, maxLength); + } + } + return undefined; + } + } catch { + // ignore parse errors and fall through + } + } + const firstLine = text.split(/\s*\n\s*/)[0]; + if (!firstLine) return undefined; + return firstLine.slice(0, maxLength); +} diff --git a/commands/listing-generate-example.ts b/commands/listing-generate-example.ts new file mode 100644 index 0000000..fcad06d --- /dev/null +++ b/commands/listing-generate-example.ts @@ -0,0 +1,85 @@ +import { resolveAdoptsFrom } from '@cardstack/runtime-common/code-ref'; +import { realmURL } from '@cardstack/runtime-common/constants'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import { GenerateExampleCardsOneShotCommand } from './generate-example-cards'; +import GetDefaultWritableRealmCommand from './get-default-writable-realm'; + +export default class ListingGenerateExampleCommand extends HostBaseCommand< + typeof BaseCommandModule.GenerateListingExampleInput, + typeof BaseCommandModule.CreateInstanceResult +> { + static actionVerb = 'Generate Example'; + description = 'Generate a new example card for the listing and link it.'; + + requireInputFields = ['listing', 'referenceExample']; + + async getInputType() { + const commandModule = await this.loadCommandModule(); + const { GenerateListingExampleInput } = commandModule; + return GenerateListingExampleInput; + } + + protected async run( + input: BaseCommandModule.GenerateListingExampleInput, + ): Promise { + const listing = input.listing as CardDef | undefined; + if (!listing || !listing.id) { + throw new Error('Listing card is required and must have an id'); + } + + const referenceExample = input.referenceExample as CardDef | undefined; + if (!referenceExample) { + throw new Error('Listing must include a reference example'); + } + + const codeRef = resolveAdoptsFrom(referenceExample); + if (!codeRef) { + throw new Error( + 'Unable to resolve card definition from reference example', + ); + } + + const { realmPath: defaultWritableRealmPath } = + await new GetDefaultWritableRealmCommand(this.commandContext).execute( + undefined, + ); + const targetRealm = + input.realm || + (referenceExample as any)[realmURL]?.href || + listing[realmURL]?.href || + defaultWritableRealmPath || + undefined; + + const generator = new GenerateExampleCardsOneShotCommand( + this.commandContext, + ); + + const result = await generator.execute({ + codeRef, + realm: targetRealm, + count: 1, + exampleCard: referenceExample, + }); + const createdExample = result.createdCard as CardDef | undefined; + if (!createdExample) { + throw new Error('Failed to create example card for listing'); + } + + this.linkListingExample(listing, createdExample); + return result; + } + + private linkListingExample(listing: CardDef, exampleCard: CardDef) { + // TODO: autoSave should take over persisting this relationship; for now we only update the local instance. + const currentExamples = Array.isArray((listing as any).examples) + ? [...((listing as any).examples as CardDef[])] + : []; + currentExamples.push(exampleCard); + (listing as any).examples = currentExamples; + } +} diff --git a/commands/listing-install.ts b/commands/listing-install.ts new file mode 100644 index 0000000..2d996d9 --- /dev/null +++ b/commands/listing-install.ts @@ -0,0 +1,218 @@ +import type { + ListingPathResolver, + ModuleResource, + LooseCardResource, +} from '@cardstack/runtime-common'; +import { + type ResolvedCodeRef, + RealmPaths, + join, + planModuleInstall, + planInstanceInstall, + PlanBuilder, + extractRelationshipIds, + type Relationship, +} from '@cardstack/runtime-common'; +import { logger } from '@cardstack/runtime-common'; +import type { CopyInstanceMeta } from '@cardstack/runtime-common/catalog'; +import type { CopyModuleMeta } from '@cardstack/runtime-common/catalog'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import ExecuteAtomicOperationsCommand from './execute-atomic-operations'; +import FetchCardJsonCommand from './fetch-card-json'; +import GetAvailableRealmUrlsCommand from './get-available-realm-urls'; +import GetCardCommand from './get-card'; +import ReadSourceCommand from './read-source'; +import SerializeCardCommand from './serialize-card'; + +import type { Listing } from '@cardstack/catalog/catalog-app/listing/listing'; + +const log = logger('catalog:install'); + +export default class ListingInstallCommand extends HostBaseCommand< + typeof BaseCommandModule.ListingInstallInput, + typeof BaseCommandModule.ListingInstallResult +> { + description = + 'Install catalog listing with bringing them to code mode, and then remixing them via AI'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { ListingInstallInput } = commandModule; + return ListingInstallInput; + } + + requireInputFields = ['realm', 'listing']; + + protected async run( + input: BaseCommandModule.ListingInstallInput, + ): Promise { + let { realm, listing: listingInput } = input; + + let realmUrl = new RealmPaths(new URL(realm)).url; + + let { urls: realmUrls } = await new GetAvailableRealmUrlsCommand( + this.commandContext, + ).execute(undefined); + if (!realmUrls.includes(realmUrl)) { + throw new Error(`Invalid realm: ${realmUrl}`); + } + + // this is intentionally to type because base command cannot interpret Listing type from catalog + const listing = listingInput as Listing; + + let examplesToInstall = listing.examples; + if (listing.examples?.length) { + examplesToInstall = await this.expandInstances(listing.examples); + } + + // side-effects + let exampleCardId: string | undefined; + let selectedCodeRef: ResolvedCodeRef | undefined; + let skillCardId: string | undefined; + + const builder = new PlanBuilder(realmUrl, listing); + + builder + .addIf(listing.specs?.length > 0, (resolver: ListingPathResolver) => { + let r = planModuleInstall(listing.specs, resolver); + selectedCodeRef = r.modulesCopy[0].targetCodeRef; + return r; + }) + .addIf(examplesToInstall?.length > 0, (resolver: ListingPathResolver) => { + let r = planInstanceInstall(examplesToInstall, resolver); + let firstInstance = r.instancesCopy[0]; + exampleCardId = join(realmUrl, firstInstance.lid); + selectedCodeRef = firstInstance.targetCodeRef; + return r; + }) + .addIf(listing.skills?.length > 0, (resolver: ListingPathResolver) => { + let r = planInstanceInstall(listing.skills, resolver); + skillCardId = join(realmUrl, r.instancesCopy[0].lid); + return r; + }); + + const plan = builder.build(); + + let sourceOperations = await Promise.all( + plan.modulesToInstall.map(async (moduleMeta: CopyModuleMeta) => { + let { sourceModule, targetModule } = moduleMeta; + let { content } = await new ReadSourceCommand( + this.commandContext, + ).execute({ path: sourceModule }); + let moduleResource: ModuleResource = { + type: 'source', + attributes: { content }, + meta: {}, + }; + let href = targetModule + '.gts'; + return { op: 'add' as const, href, data: moduleResource }; + }), + ); + + let instanceOperations = await Promise.all( + plan.instancesCopy.map(async (copyInstanceMeta: CopyInstanceMeta) => { + let { sourceCard } = copyInstanceMeta; + let { document: doc } = await new FetchCardJsonCommand( + this.commandContext, + ).execute({ url: sourceCard.id }); + if (!doc || !('data' in doc)) { + throw new Error('We are only expecting single documents returned'); + } + delete (doc as any).data.id; + delete (doc as any).included; + let cardResource: LooseCardResource = (doc as any).data as LooseCardResource; + let href = join(realmUrl, copyInstanceMeta.lid) + '.json'; + return { op: 'add' as const, href, data: cardResource }; + }), + ); + + const operations = [...sourceOperations, ...instanceOperations]; + + const { results: atomicResults } = await new ExecuteAtomicOperationsCommand( + this.commandContext, + ).execute({ realmUrl, operations }); + + let writtenFiles = (atomicResults as Array>).map( + (r) => r.data?.id, + ); + log.debug('=== Final Results ==='); + log.debug(JSON.stringify(writtenFiles, null, 2)); + + let commandModule = await this.loadCommandModule(); + const { ListingInstallResult } = commandModule; + return new ListingInstallResult({ + selectedCodeRef, + exampleCardId, + skillCardId, + }); + } + + // Walk relationships by fetching linked cards and enqueueing their ids. + private async expandInstances(instances: CardDef[]): Promise { + let instancesById = new Map(); + let visited = new Set(); + let queue: string[] = instances + .map((instance) => instance.id) + .filter((id): id is string => typeof id === 'string'); + + // - Queue of ids to traverse; visited prevents duplicate relationship ids. + // - Each loop extracts relationship ids and enqueues them, so we descend + // through the relationship graph breadth-first. + while (queue.length > 0) { + let id = queue.shift(); + if (!id || visited.has(id)) { + continue; + } + visited.add(id); + + let instance = (await new GetCardCommand(this.commandContext).execute({ + cardId: id, + })) as CardDef; + instancesById.set(instance.id ?? id, instance); + + let { json: serialized } = await new SerializeCardCommand( + this.commandContext, + ).execute({ cardId: id }); + let baseUrl: string = (serialized as any)?.data?.id ?? id; + let relationships: Record = + (serialized as any)?.data?.relationships ?? {}; + + let entries = Object.entries(relationships); + log.debug(`Relationships for ${id}:`); + if (entries.length === 0) { + log.debug('[]'); + continue; + } + let summary = entries.map(([field, rel]) => { + let rels = Array.isArray(rel) ? rel : [rel]; + return { + field, + relationships: rels.map((relationship) => ({ + links: relationship.links ?? null, + data: relationship.data ?? null, + })), + }; + }); + log.debug(JSON.stringify(summary, null, 2)); + + for (let rel of Object.values(relationships)) { + let rels = Array.isArray(rel) ? rel : [rel]; + for (let relationship of rels) { + let relatedIds = extractRelationshipIds(relationship, baseUrl); + for (let relatedId of relatedIds) { + if (!visited.has(relatedId)) { + queue.push(relatedId); + } + } + } + } + } + + return [...instancesById.values()]; + } +} diff --git a/commands/listing-remix.ts b/commands/listing-remix.ts new file mode 100644 index 0000000..fb80062 --- /dev/null +++ b/commands/listing-remix.ts @@ -0,0 +1,158 @@ +import { + isResolvedCodeRef, + RealmPaths, + type ResolvedCodeRef, +} from '@cardstack/runtime-common'; +import { DEFAULT_CODING_LLM } from '@cardstack/runtime-common/matrix-constants'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import { skillCardURL, devSkillId, envSkillId } from '../lib/utils'; + +import UseAiAssistantCommand from './ai-assistant'; +import GetAvailableRealmUrlsCommand from './get-available-realm-urls'; +import ListingInstallCommand from './listing-install'; +import PersistModuleInspectorViewCommand from './persist-module-inspector-view'; +import SwitchSubmodeCommand from './switch-submode'; +import UpdateCodePathWithSelectionCommand from './update-code-path-with-selection'; +import UpdatePlaygroundSelectionCommand from './update-playground-selection'; + +import type { Listing } from '@cardstack/catalog/catalog-app/listing/listing'; + +export default class RemixCommand extends HostBaseCommand< + typeof BaseCommandModule.ListingInstallInput +> { + + static actionVerb = 'Remix'; + + description = + 'Install catalog listing with bringing them to code mode, and then remixing them via AI'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { ListingInstallInput } = commandModule; + return ListingInstallInput; + } + + requireInputFields = ['realm', 'listing']; + + private isThemeListing(listing: Listing): boolean { + return listing?.constructor?.name === 'ThemeListing'; + } + + private async navigateView(options: { + listing: Listing; + selectedCodeRef?: ResolvedCodeRef; + exampleCardId?: string; + skillCardId?: string; + }) { + const { listing, selectedCodeRef, exampleCardId, skillCardId } = options; + + if (this.isThemeListing(listing)) { + if (exampleCardId) { + await new SwitchSubmodeCommand(this.commandContext).execute({ + submode: 'code', + codePath: `${exampleCardId}.json`, + }); + } + return; + } + + if (selectedCodeRef && isResolvedCodeRef(selectedCodeRef)) { + const codePath = selectedCodeRef.module; + + if (exampleCardId) { + const moduleId = [selectedCodeRef.module, selectedCodeRef.name].join( + '/', + ); + await new UpdatePlaygroundSelectionCommand(this.commandContext).execute( + { + moduleId: moduleId, + cardId: exampleCardId, + format: 'isolated', + fieldIndex: undefined, + }, + ); + + await new PersistModuleInspectorViewCommand(this.commandContext).execute( + { + codePath: codePath + '.gts', + moduleInspectorView: 'preview', + }, + ); + } + + await new UpdateCodePathWithSelectionCommand(this.commandContext).execute( + { + codeRef: selectedCodeRef, + localName: selectedCodeRef.name, + fieldName: undefined, + }, + ); + await new SwitchSubmodeCommand(this.commandContext).execute({ + submode: 'code', + codePath: selectedCodeRef.module, + }); + } else if ('skills' in listing) { + // A listing can have more than one skill + // The most optimum way for remixing is still to display only the first instance + if (skillCardId) { + await new SwitchSubmodeCommand(this.commandContext).execute({ + submode: 'code', + codePath: skillCardId, + }); + } + } + } + + protected async run( + input: BaseCommandModule.ListingInstallInput, + ): Promise { + let { realm, listing: listingInput } = input; + let realmUrl = new RealmPaths(new URL(realm)).url; + + // Make sure realm is valid + let { urls: realmUrls } = await new GetAvailableRealmUrlsCommand( + this.commandContext, + ).execute(undefined); + if (!realmUrls.includes(realmUrl)) { + throw new Error(`Invalid realm: ${realmUrl}`); + } + + // this is intentionally to type because base command cannot interpret Listing type from catalog + const listing = listingInput as Listing; + + const { selectedCodeRef, exampleCardId, skillCardId } = + await new ListingInstallCommand(this.commandContext).execute({ + realm: realmUrl, + listing, + }); + await this.navigateView({ + listing, + selectedCodeRef, + exampleCardId, + skillCardId, + }); + + let prompt = + 'Remix done! Please suggest two example prompts on how to edit this card.'; + + const skillCardIds = [ + devSkillId, + envSkillId, + skillCardURL('source-code-editing'), + skillCardURL('catalog-listing'), + ]; + await new UseAiAssistantCommand(this.commandContext).execute({ + roomId: 'new', + prompt, + openRoom: true, + roomName: `Remixing ${listing.name ?? 'Listing'} `, + attachedCards: [listing], + skillCardIds, + llmModel: DEFAULT_CODING_LLM, + }); + } +} diff --git a/commands/listing-update-specs.ts b/commands/listing-update-specs.ts new file mode 100644 index 0000000..3c6e773 --- /dev/null +++ b/commands/listing-update-specs.ts @@ -0,0 +1,146 @@ +import { isScopedCSSRequest } from 'glimmer-scoped-css'; + +import { isCardInstance, SupportedMimeType } from '@cardstack/runtime-common'; + +import { realmURL as realmURLSymbol } from '@cardstack/runtime-common'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; +import type { Spec } from 'https://cardstack.com/base/spec'; + +import HostBaseCommand from '../lib/host-base-command'; + +import AuthedFetchCommand from './authed-fetch'; +import CanReadRealmCommand from './can-read-realm'; +import CreateSpecCommand from './create-specs'; +import GetRealmOfUrlCommand from './get-realm-of-url'; + +export default class ListingUpdateSpecsCommand extends HostBaseCommand< + typeof BaseCommandModule.ListingUpdateSpecsInput, + typeof BaseCommandModule.ListingUpdateSpecsResult +> { + static actionVerb = 'Update'; + description = 'Update listing specs based on example dependencies'; + requireInputFields = ['listing']; + + async getInputType() { + const commandModule = await this.loadCommandModule(); + let { ListingUpdateSpecsInput } = commandModule; + return ListingUpdateSpecsInput; + } + + private async sanitizeDeps(deps: string[]): Promise { + const results = await Promise.all( + deps.map(async (dep) => { + if (isScopedCSSRequest(dep)) { + return null; + } + if ( + [ + 'https://cardstack.com', + 'https://packages', + 'https://boxel-icons.boxel.ai', + ].some((urlStem) => dep.startsWith(urlStem)) + ) { + return null; + } + try { + const { realmUrl } = await new GetRealmOfUrlCommand( + this.commandContext, + ).execute({ url: dep }); + if (!realmUrl) { + return null; + } + const { canRead } = await new CanReadRealmCommand( + this.commandContext, + ).execute({ realmUrl }); + return canRead ? dep : null; + } catch { + return null; + } + }), + ); + return results.filter((dep): dep is string => dep !== null); + } + + protected async run( + input: BaseCommandModule.ListingUpdateSpecsInput, + ): Promise { + const listing = input.listing; + if (!listing) { + throw new Error('listing is required'); + } + if (!isCardInstance(listing)) { + throw new Error('listing must be a valid card instance'); + } + + const targetRealm = (listing as any)?.[realmURLSymbol]?.href; + + if (!targetRealm) { + throw new Error('targetRealm is required to update specs'); + } + + const exampleId = (listing as any).examples?.[0]?.id; + if (!exampleId) { + throw new Error('No example found in listing to derive specs from'); + } + + const { ok, body: jsonApiResponse } = await new AuthedFetchCommand( + this.commandContext, + ).execute({ + url: `${targetRealm}_dependencies?url=${encodeURIComponent(exampleId)}`, + acceptHeader: SupportedMimeType.JSONAPI, + }); + if (!ok) { + throw new Error('Failed to fetch dependencies for listing'); + } + + // Extract dependencies from all entries in the JSONAPI response + const deps: string[] = []; + const responseData = ( + jsonApiResponse as { + data?: Array<{ attributes?: { dependencies?: string[] } }>; + } + ).data; + if (responseData && Array.isArray(responseData)) { + for (const entry of responseData) { + if ( + entry.attributes?.dependencies && + Array.isArray(entry.attributes.dependencies) + ) { + deps.push(...entry.attributes.dependencies); + } + } + } + + const sanitizedDeps = await this.sanitizeDeps(deps); + const commandModule = await this.loadCommandModule(); + if (!sanitizedDeps.length) { + (listing as any).specs = []; + return new commandModule.ListingUpdateSpecsResult({ + listing, + specs: [], + }); + } + + const createSpecCommand = new CreateSpecCommand(this.commandContext); + const specResults = await Promise.all( + sanitizedDeps.map((dep) => + createSpecCommand + .execute({ module: dep, targetRealm, autoGenerateReadme: true }) + .catch((e) => { + console.warn('Failed to create spec(s) for', dep, e); + return undefined; + }), + ), + ); + + const specs: Spec[] = []; + for (const res of specResults) { + if (res?.specs) { + specs.push(...res.specs); + } + } + (listing as any).specs = specs; + return new commandModule.ListingUpdateSpecsResult({ listing, specs }); + } +} diff --git a/commands/listing-use.ts b/commands/listing-use.ts new file mode 100644 index 0000000..9b42f5d --- /dev/null +++ b/commands/listing-use.ts @@ -0,0 +1,105 @@ +import { + cardIdToURL, + codeRefWithAbsoluteURL, + isResolvedCodeRef, + loadCardDef, + generateInstallFolderName, + RealmPaths, +} from '@cardstack/runtime-common'; + +import type * as CardAPI from 'https://cardstack.com/base/card-api'; +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import type { Skill } from 'https://cardstack.com/base/skill'; + +import HostBaseCommand from '../lib/host-base-command'; + +import CopyCardToRealmCommand from './copy-card'; +import GetAvailableRealmUrlsCommand from './get-available-realm-urls'; +import SaveCardCommand from './save-card'; + +import type { Listing } from '@cardstack/catalog/catalog-app/listing/listing'; + +export default class ListingUseCommand extends HostBaseCommand< + typeof BaseCommandModule.ListingInstallInput +> { + description = 'Catalog listing use command'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { ListingInstallInput } = commandModule; + return ListingInstallInput; + } + + requireInputFields = ['realm', 'listing']; + + protected async run( + input: BaseCommandModule.ListingInstallInput, + ): Promise { + let { realm, listing: listingInput } = input; + + const listing = listingInput as Listing; + + let realmUrl = new RealmPaths(new URL(realm)).url; + + // Make sure realm is valid + let { urls: realmUrls } = await new GetAvailableRealmUrlsCommand( + this.commandContext, + ).execute(undefined); + if (!realmUrls.includes(realmUrl)) { + throw new Error(`Invalid realm: ${realmUrl}`); + } + + const specsToCopy = listing.specs ?? []; + const specsWithoutFields = specsToCopy.filter( + (spec) => spec.specType !== 'field', + ); + + const localDir = generateInstallFolderName(listing.name); + + for (const spec of specsWithoutFields) { + if (spec.isComponent) { + return; + } + let url = cardIdToURL(spec.id); + let ref = codeRefWithAbsoluteURL(spec.ref, url); + if (!isResolvedCodeRef(ref)) { + throw new Error('ref is not a resolved code ref'); + } + let Klass = await loadCardDef(ref, { + loader: this.loaderService.loader, + }); + let card = new Klass({}) as CardAPI.CardDef; + await new SaveCardCommand(this.commandContext).execute({ + card, + realm: realmUrl, + localDir, + }); + } + + if (listing.examples) { + const sourceCards = (listing.examples as CardAPI.CardDef[]).map( + (example) => example, + ); + for (const card of sourceCards) { + await new CopyCardToRealmCommand(this.commandContext).execute({ + sourceCard: card, + targetRealm: realmUrl, + localDir, + }); + } + } + + if ('skills' in listing && Array.isArray(listing.skills)) { + await Promise.all( + listing.skills.map((skill: Skill) => + new CopyCardToRealmCommand(this.commandContext).execute({ + sourceCard: skill, + targetRealm: realmUrl, + localDir, + }), + ), + ); + } + } +} diff --git a/commands/persist-module-inspector-view.ts b/commands/persist-module-inspector-view.ts new file mode 100644 index 0000000..25e67c4 --- /dev/null +++ b/commands/persist-module-inspector-view.ts @@ -0,0 +1,34 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type OperatorModeStateService from '../services/operator-mode-state-service'; + +export default class PersistModuleInspectorViewCommand extends HostBaseCommand< + typeof BaseCommandModule.PersistModuleInspectorViewInput, + undefined +> { + @service declare private operatorModeStateService: OperatorModeStateService; + + description = 'Persist the module inspector view selection to local storage'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { PersistModuleInspectorViewInput } = commandModule; + return PersistModuleInspectorViewInput; + } + + requireInputFields = ['codePath', 'moduleInspectorView']; + + protected async run( + input: BaseCommandModule.PersistModuleInspectorViewInput, + ): Promise { + this.operatorModeStateService.persistModuleInspectorView( + input.codePath, + input.moduleInspectorView as 'schema' | 'spec' | 'preview', + ); + return undefined; + } +} diff --git a/commands/store-add.ts b/commands/store-add.ts new file mode 100644 index 0000000..0688abc --- /dev/null +++ b/commands/store-add.ts @@ -0,0 +1,37 @@ +import { service } from '@ember/service'; + +import type { LooseSingleCardDocument } from '@cardstack/runtime-common'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type StoreService from '../services/store'; + +export default class StoreAddCommand extends HostBaseCommand< + typeof BaseCommandModule.StoreAddInput, + typeof CardDef +> { + @service declare private store: StoreService; + + description = 'Add a card document to the store'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { StoreAddInput } = commandModule; + return StoreAddInput; + } + + requireInputFields = ['document']; + + protected async run( + input: BaseCommandModule.StoreAddInput, + ): Promise { + const result = await this.store.add( + input.document as LooseSingleCardDocument, + input.realm ? { realm: input.realm } : undefined, + ); + return result as CardDef; + } +}