From 770a27e13018b5ee23bd675190ca00f18b3c70f9 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Wed, 4 Feb 2026 14:15:25 +0800 Subject: [PATCH 1/3] Add acceptance and rest integration test --- .github/workflows/ci-lint.yaml | 3 + .github/workflows/ci-test.yaml | 19 +- catalog-app/listing/listing.gts | 4 +- scripts/test-wait-for-servers.sh | 43 + tests/acceptance/catalog-app-test.gts | 2572 +++++++++++++++++ tests/acceptance/real-catalog-app-test.gts | 67 + .../commands/set-user-system-card-test.gts | 63 + .../integration/listing-update-specs-test.gts | 185 ++ tests/integration/upload-image-test.gts | 389 +++ 9 files changed, 3341 insertions(+), 4 deletions(-) create mode 100755 scripts/test-wait-for-servers.sh create mode 100644 tests/acceptance/catalog-app-test.gts create mode 100644 tests/acceptance/real-catalog-app-test.gts create mode 100644 tests/integration/commands/set-user-system-card-test.gts create mode 100644 tests/integration/listing-update-specs-test.gts create mode 100644 tests/integration/upload-image-test.gts diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index e3abbaf..95f0799 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -63,7 +63,10 @@ jobs: # Copy catalog integration tests to host tests directory rm -rf packages/host/tests/integration + rm -rf packages/host/tests/acceptance mkdir -p packages/host/tests/integration + mkdir -p packages/host/tests/acceptance + cp -r boxel-catalog-src/tests/acceptance/* packages/host/tests/acceptance cp -r boxel-catalog-src/tests/integration/* packages/host/tests/integration cp -r boxel-catalog-src/tests/helpers/* packages/host/tests/helpers diff --git a/.github/workflows/ci-test.yaml b/.github/workflows/ci-test.yaml index 3e57bbf..f11d262 100644 --- a/.github/workflows/ci-test.yaml +++ b/.github/workflows/ci-test.yaml @@ -60,12 +60,19 @@ jobs: # Copy catalog source files (exclude config files that should stay from monorepo) rsync -av --exclude='tsconfig.json' --exclude='package.json' --exclude='.realm.json' --exclude='.gitignore' --exclude='tests/' boxel-catalog-src/ packages/catalog-realm/ - # Copy catalog integration tests to host tests directory + # Copy catalog acceptance and integration tests to host tests directory rm -rf packages/host/tests/integration + rm -rf packages/host/tests/acceptance mkdir -p packages/host/tests/integration + mkdir -p packages/host/tests/acceptance + cp -r boxel-catalog-src/tests/acceptance/* packages/host/tests/acceptance cp -r boxel-catalog-src/tests/integration/* packages/host/tests/integration cp -r boxel-catalog-src/tests/helpers/* packages/host/tests/helpers - + + + # Overwrite the test-wait-for-server script to include catalog server start + cp boxel-catalog-src/scripts/test-wait-for-servers.sh packages/host/scripts/test-wait-for-servers.sh + - uses: ./.github/actions/init - name: Build common dependencies @@ -105,6 +112,14 @@ jobs: sudo service dbus restart sudo service upower restart + - name: Run Catalog Acceptance Tests + run: dbus-run-session -- pnpm exec ember exam --path ./dist --filter="Acceptance | " + env: + PERCY_GZIP: true + PERCY_TOKEN: ${{ secrets.PERCY_TOKEN_HOST }} + DBUS_SYSTEM_BUS_ADDRESS: unix:path=/run/dbus/system_bus_socket + working-directory: packages/host + - name: Run Catalog Integration Tests run: dbus-run-session -- pnpm exec ember exam --path ./dist --filter="Integration | " env: diff --git a/catalog-app/listing/listing.gts b/catalog-app/listing/listing.gts index 2bca284..1597919 100644 --- a/catalog-app/listing/listing.gts +++ b/catalog-app/listing/listing.gts @@ -596,7 +596,7 @@ export class Listing extends CardDef { return undefined; } return { - label: 'Generate example with AI', + label: 'Generate Example with AI', action: async () => { const command = new ListingGenerateExampleCommand( params.commandContext, @@ -677,7 +677,7 @@ export class Listing extends CardDef { label: 'Make a PR', action: async () => { await new CreateListingPRCommand(commandContext).execute({ - listing: this, + listingId: this.id, realm: this[realmURL]!.href, }); }, diff --git a/scripts/test-wait-for-servers.sh b/scripts/test-wait-for-servers.sh new file mode 100755 index 0000000..9c469cc --- /dev/null +++ b/scripts/test-wait-for-servers.sh @@ -0,0 +1,43 @@ +#! /bin/sh + +ensure_trailing_slash() { + case "$1" in + */) printf '%s' "$1" ;; + *) printf '%s/' "$1" ;; + esac +} + +to_wait_url() { + case "$1" in + http://*) printf 'http-get://%s' "${1#http://}" ;; + https://*) printf 'https-get://%s' "${1#https://}" ;; + *) printf '%s' "$1" ;; + esac +} + +DEFAULT_BASE_REALM_URL='http://localhost:4201/base/' +DEFAULT_CATALOG_REALM_URL='http://localhost:4201/catalog/' +DEFAULT_SKILLS_REALM_URL='http://localhost:4201/skills/' + +BASE_REALM_URL=$(ensure_trailing_slash "${RESOLVED_BASE_REALM_URL:-$DEFAULT_BASE_REALM_URL}") +CATALOG_REALM_URL=$(ensure_trailing_slash "${RESOLVED_CATALOG_REALM_URL:-$DEFAULT_CATALOG_REALM_URL}") +SKILLS_REALM_URL=$(ensure_trailing_slash "${RESOLVED_SKILLS_REALM_URL:-$DEFAULT_SKILLS_REALM_URL}") + +NODE_TEST_REALM="http-get://localhost:4202/node-test/" +TEST_REALM="http-get://localhost:4202/test/" + +READY_PATH="_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson" + +BASE_REALM_READY="$(to_wait_url "${BASE_REALM_URL}")${READY_PATH}" +CATALOG_REALM_READY="$(to_wait_url "${CATALOG_REALM_URL}")${READY_PATH}" +NODE_TEST_REALM_READY="$NODE_TEST_REALM$READY_PATH" +SKILLS_REALM_READY="$(to_wait_url "${SKILLS_REALM_URL}")${READY_PATH}" +TEST_REALM_READY="$TEST_REALM$READY_PATH" + +SYNAPSE_URL="http://localhost:8008" +SMTP_4_DEV_URL="http://localhost:5001" + +WAIT_ON_TIMEOUT=600000 NODE_NO_WARNINGS=1 start-server-and-test \ + 'pnpm run wait' \ + "$BASE_REALM_READY|$CATALOG_REALM_READY|$NODE_TEST_REALM_READY|$SKILLS_REALM_READY|$TEST_REALM_READY|$SYNAPSE_URL|$SMTP_4_DEV_URL" \ + 'ember-test-pre-built' diff --git a/tests/acceptance/catalog-app-test.gts b/tests/acceptance/catalog-app-test.gts new file mode 100644 index 0000000..9ea064f --- /dev/null +++ b/tests/acceptance/catalog-app-test.gts @@ -0,0 +1,2572 @@ +import { + click, + waitFor, + waitUntil, + fillIn, + settled, + triggerEvent, +} 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/host/commands/listing-create'; +import ListingInstallCommand from '@cardstack/host/commands/listing-install'; +import ListingRemixCommand from '@cardstack/host/commands/listing-remix'; +import ListingUseCommand from '@cardstack/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, +} from '../helpers'; +import { setupMockMatrix } from '../helpers/mock-matrix'; +import { setupApplicationTest } from '../helpers/setup'; + +import type { CardListing } from '@cardstack/catalog/listing/listing'; + +const catalogRealmURL = ensureTrailingSlash(ENV.resolvedCatalogRealmURL); +const testDestinationRealmURL = `http://test-realm/test2/`; + +//listing +const authorListingId = `${mockCatalogURL}Listing/author`; +const personListingId = `${mockCatalogURL}Listing/person`; +const emptyListingId = `${mockCatalogURL}Listing/empty`; +const pirateSkillListingId = `${mockCatalogURL}SkillListing/pirate-skill`; +const incompleteSkillListingId = `${mockCatalogURL}Listing/incomplete-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`; +//publisher +const publisherId = `${mockCatalogURL}Publisher/boxel-publisher`; + +//skills +const pirateSkillId = `${mockCatalogURL}Skill/pirate-speak`; + +//tags +const calculatorTagId = `${mockCatalogURL}Tag/c1fe433a-b3df-41f4-bdcf-d98686ee42d7`; +const gameTagId = `${mockCatalogURL}Tag/51de249c-516a-4c4d-bd88-76e88274c483`; +const stubTagId = `${mockCatalogURL}Tag/stub`; + +//specs +const authorSpecId = `${mockCatalogURL}Spec/author`; +const unknownSpecId = `${mockCatalogURL}Spec/unknown-no-type`; + +//examples +const authorExampleId = `${mockCatalogURL}author/Author/example`; +const authorCompanyExampleId = `${mockCatalogURL}author/AuthorCompany/example`; + +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); + } +`; + +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); + } +`; + +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); + } +`; + +const appCardSource = ` + import { CardDef } from 'https://cardstack.com/base/card-api'; + + export class AppCard extends CardDef { + static displayName = 'App Card'; + static prefersWideFormat = true; + } +`; + +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); + } +`; + +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); + } +`; + +module('Acceptance | Catalog | catalog app tests', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [mockCatalogURL, testDestinationRealmURL], + }); + + let { getRoomIds, createAndJoinRoom } = mockMatrixUtils; + + hooks.beforeEach(async function () { + createAndJoinRoom({ + sender: '@testuser:localhost', + name: 'room-test', + }); + setupUserSubscription(); + setupAuthEndpoints(); + // this setup test realm is pretending to be a mock catalog + await setupAcceptanceTestRealm({ + realmURL: mockCatalogURL, + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + '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', + }, + }, + }); + await setupAcceptanceTestRealm({ + mockMatrixUtils, + realmURL: testDestinationRealmURL, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + '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', + }, + }, + }); + }); + + /** + * Selects a tab by name within the catalog app + */ + async function selectTab(tabName: string) { + await waitFor(`[data-test-catalog-app] [data-test-tab-label="${tabName}"]`); + await click(`[data-test-catalog-app] [data-test-tab-label="${tabName}"]`); + } + + /** + * Waits for grid to load in the catalog app + */ + async function waitForGrid() { + await waitFor('[data-test-catalog-list-view]'); + await waitFor('[data-test-cards-grid-cards]'); + await settled(); + } + + /** + * Waits for showcase view to load + */ + async function waitForShowcase() { + await waitFor('[data-test-showcase-view]'); + await settled(); + } + + /** + * Waits for room operations to complete + */ + async function waitForRoom() { + await waitFor('[data-room-settled]'); + await settled(); + } + + /** + * Waits for a card to appear on the grid with optional title verification + */ + async function waitForCardOnGrid(cardId: string, title?: string) { + await waitFor(`[data-test-cards-grid-item="${cardId}"]`); + if (title) { + await waitFor( + `[data-test-cards-grid-item="${cardId}"] [data-test-card-title="${title}"]`, + //its problematic when we are waiting for computed title + //my recommendation for the purposes of test is to populate the card title in the realm + ); + } + } + + /** + * 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 clickDropdownItem(menuItemText: string) { + let selector = `[data-test-boxel-dropdown-content] [data-test-boxel-menu-item-text="${menuItemText}"]`; + await waitFor(selector); + await click(selector); + } + + async function hoverToHydrateCard(buttonSelector: string) { + await waitFor(buttonSelector); + await triggerEvent(buttonSelector, 'mouseenter'); + await waitFor('[data-test-hydrated-card]'); + } + + async function openMenu(buttonSelector: string, checkHydration = true) { + await waitFor(buttonSelector); + await triggerEvent(buttonSelector, 'mouseenter'); + if (checkHydration) { + await waitFor('[data-test-hydrated-card]'); + } + await click(buttonSelector); + } + + async function executeListingAction( + buttonSelector: string, + menuItemText: string, + checkHydration = true, + ) { + await openMenu(buttonSelector, checkHydration); + await clickDropdownItem(menuItemText); + } + + async function verifyListingAction( + assert: Assert, + buttonSelector: string, + expectedText: string, + expectedMessage: string, + menuItemName = 'Test Workspace B', + checkHydration = true, + ) { + await waitFor(buttonSelector); + assert.dom(buttonSelector).containsText(expectedText); + await executeListingAction(buttonSelector, menuItemName, checkHydration); + await waitUntil(() => getRoomIds().length > 0); + + const roomId = getRoomIds().pop()!; + await waitFor(`[data-test-room="${roomId}"][data-test-room-settled]`); + await waitFor( + `[data-test-room="${roomId}"] [data-test-ai-assistant-message]`, + ); + + await waitFor( + `[data-test-room="${roomId}"] [data-test-ai-message-content]`, + ); + await settled(); + + assert + .dom(`[data-test-room="${roomId}"] [data-test-ai-message-content]`) + .containsText(expectedMessage); + } + + async function assertDropdownItem( + assert: Assert, + menuItemText: string, + exists = true, + ) { + let selector = `[data-test-boxel-dropdown-content] [data-test-boxel-menu-item-text="${menuItemText}"]`; + if (exists) { + await waitFor(selector); + assert.dom(selector).exists(); + } else { + assert.dom(selector).doesNotExist(); + } + } + + 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('catalog index', function (hooks) { + hooks.beforeEach(async function () { + await visitOperatorMode({ + stacks: [ + [ + { + id: `${mockCatalogURL}index`, + format: 'isolated', + }, + ], + ], + }); + await waitForShowcase(); + }); + + module('listing fitted', function () { + test('after clicking "Remix" button, the ai room is initiated, and prompt is given correctly', async function (assert) { + await selectTab('Cards'); + await waitForGrid(); + await waitForCardOnGrid(authorListingId, 'Author'); + assert + .dom( + `[data-test-cards-grid-item="${authorListingId}"] [data-test-card-title="Author"]`, + ) + .containsText('Author', '"Author" exist in listing'); + await verifyListingAction( + assert, + `[data-test-cards-grid-item="${authorListingId}"] [data-test-catalog-listing-action="Remix"]`, + 'Remix', + 'Remix done! Please suggest two example prompts on how to edit this card.', + 'Test Workspace B', + ); + }); + + test('after clicking "Remix" button, current realm (particularly catalog realm) is never displayed in realm options', async function (assert) { + await selectTab('Cards'); + await waitForGrid(); + const listingId = mockCatalogURL + 'Listing/author'; + await waitFor(`[data-test-cards-grid-item="${listingId}"]`); + await waitFor( + `[data-test-cards-grid-item="${listingId}"] [data-test-card-title="Author"]`, + ); + await openMenu( + `[data-test-cards-grid-item="${listingId}"] [data-test-catalog-listing-action="Remix"]`, + ); + assert + .dom('[data-test-boxel-dropdown-content] [data-test-boxel-menu-item]') + .exists({ count: 1 }); + await assertDropdownItem(assert, 'Test Workspace B'); + await assertDropdownItem(assert, 'Test Workspace A', false); + }); + + test('after clicking "Preview" button, the first example card opens up onto the stack', async function (assert) { + await waitForCardOnGrid(authorListingId, 'Author'); + assert + .dom( + `[data-test-card="${authorListingId}"] [data-test-card-title="Author"]`, + ) + .containsText('Author', '"Author" button exist in listing'); + await click( + `[data-test-cards-grid-item="${authorListingId}"] [data-test-catalog-listing-fitted-preview-button]`, + ); + await waitForCardOnStack(`${mockCatalogURL}author/Author/example`); + assert + .dom( + `[data-test-stack-card="${mockCatalogURL}author/Author/example"] [data-test-boxel-card-header-title]`, + ) + .hasText('Author - Mike Dane'); + }); + + test('after clicking "Use Skills" button, the skills is attached to the skill menu', async function (assert) { + await selectTab('Skills'); + await waitForGrid(); + await waitFor(`[data-test-cards-grid-item="${pirateSkillListingId}"]`); + await openMenu( + `[data-test-cards-grid-item="${pirateSkillListingId}"] [data-test-catalog-listing-fitted-add-skills-to-room-button]`, + ); + await waitForRoom(); + await click('[data-test-skill-menu][data-test-pill-menu-button]'); + await waitFor('[data-test-skill-menu]'); + assert.dom('[data-test-skill-menu]').exists('Skill menu is visible'); + assert + .dom('[data-test-pill-menu-item]') + .containsText('Talk Like a Pirate') + .exists('Skill is attached to the skill menu'); + }); + + test('after clicking "carousel" area, the first example card opens up onto the stack', async function (assert) { + await waitForCardOnGrid(authorListingId, 'Author'); + assert + .dom( + `[data-test-card="${authorListingId}"] [data-test-card-title="Author"]`, + ) + .containsText('Author', '"Author" button exist in listing'); + await click( + `[data-test-cards-grid-item="${authorListingId}"] [data-test-catalog-listing-fitted-preview-button]`, + ); + await waitForCardOnStack(`${mockCatalogURL}author/Author/example`); + assert + .dom( + `[data-test-stack-card="${mockCatalogURL}author/Author/example"] [data-test-boxel-card-header-title]`, + ) + .hasText('Author - Mike Dane'); + }); + + test('after clicking "Details" button, the listing details card opens up onto the stack', async function (assert) { + await click( + `[data-test-cards-grid-item="${authorListingId}"] [data-test-catalog-listing-fitted-details-button]`, + ); + await waitForCardOnStack(authorListingId); + assert + .dom( + `[data-test-stack-card="${authorListingId}"] [data-test-boxel-card-header-title]`, + ) + .hasText('CardListing - Author'); + }); + + test('after clicking "info-section" area, the listing details card opens up onto the stack', async function (assert) { + await click( + `[data-test-card="${authorListingId}"] [data-test-catalog-listing-fitted-details]`, + ); + await waitForCardOnStack(authorListingId); + assert + .dom( + `[data-test-stack-card="${authorListingId}"] [data-test-boxel-card-header-title]`, + ) + .hasText('CardListing - Author'); + }); + + test('no arrows and dots appear when one or less image exist', async function (assert) { + await selectTab('Cards'); + await waitForGrid(); + await waitForCardOnGrid(emptyListingId); + await hoverToHydrateCard( + `[data-test-cards-grid-item="${emptyListingId}"]`, + ); + + const carouselNav = document.querySelector( + `[data-test-cards-grid-item="${emptyListingId}"] .carousel-nav`, + ); + const carouselDots = document.querySelector( + `[data-test-cards-grid-item="${emptyListingId}"] .carousel-dots`, + ); + + if (carouselNav && carouselDots) { + assert + .dom( + `[data-test-cards-grid-item="${emptyListingId}"] .carousel-arrow-prev`, + ) + .exists(); + assert + .dom( + `[data-test-cards-grid-item="${emptyListingId}"] .carousel-arrow-next`, + ) + .exists(); + assert + .dom( + `[data-test-cards-grid-item="${emptyListingId}"] .carousel-item-0.is-active`, + ) + .exists(); + } else { + assert + .dom( + `[data-test-cards-grid-item="${emptyListingId}"] .carousel-nav`, + ) + .doesNotExist(); + assert + .dom( + `[data-test-cards-grid-item="${emptyListingId}"] .carousel-dots`, + ) + .doesNotExist(); + assert + .dom( + `[data-test-cards-grid-item="${emptyListingId}"] .carousel-arrow-prev`, + ) + .doesNotExist(); + assert + .dom( + `[data-test-cards-grid-item="${emptyListingId}"] .carousel-arrow-next`, + ) + .doesNotExist(); + } + }); + + test('carousel arrows only when multiple images exist and works when triggered', async function (assert) { + await selectTab('Cards'); + await waitForGrid(); + await waitForCardOnGrid(personListingId); + await hoverToHydrateCard( + `[data-test-cards-grid-item="${personListingId}"]`, + ); + + await click( + `[data-test-cards-grid-item="${personListingId}"] .carousel-arrow-prev`, + ); + assert + .dom( + `[data-test-cards-grid-item="${personListingId}"] .carousel-item-2.is-active`, + ) + .exists('After clicking prev, last slide (index 2) is active'); + + await click( + `[data-test-cards-grid-item="${personListingId}"] .carousel-arrow-next`, + ); + assert + .dom( + `[data-test-cards-grid-item="${personListingId}"] .carousel-item-0.is-active`, + ) + .exists('After clicking next, first slide (index 0) is active'); + }); + + test('carousel dots appear only when multiple images exist and works when triggered', async function (assert) { + await selectTab('Cards'); + await waitForGrid(); + await waitForCardOnGrid(personListingId); + + // Hover over the carousel to make controls visible + await hoverToHydrateCard( + `[data-test-cards-grid-item="${personListingId}"]`, + ); + + const dots = document.querySelectorAll( + `[data-test-cards-grid-item="${personListingId}"] .carousel-dot`, + ); + + if (dots.length > 1) { + await click(dots[1]); + assert + .dom( + `[data-test-cards-grid-item="${personListingId}"] .carousel-item-1.is-active`, + ) + .exists('After clicking dot 1, slide 1 is active'); + } + }); + + test('preview button appears only when examples exist', async function (assert) { + await selectTab('Cards'); + await waitForGrid(); + await waitForCardOnGrid(authorListingId); + await hoverToHydrateCard( + `[data-test-cards-grid-item="${authorListingId}"]`, + ); + const previewButton = document.querySelector( + `[data-test-cards-grid-item="${authorListingId}"] [data-test-catalog-listing-fitted-preview-button]`, + ); + + if (previewButton) { + assert + .dom( + `[data-test-cards-grid-item="${authorListingId}"] [data-test-catalog-listing-fitted-preview-button]`, + ) + .exists(); + } else { + assert + .dom( + `[data-test-cards-grid-item="${authorListingId}"] [data-test-catalog-listing-fitted-preview-button]`, + ) + .doesNotExist(); + } + }); + }); + + module('navigation', function () { + // showcase tab has different behavior compared to other tabs (apps, cards, fields, skills) + module('show results as per catalog tab selected', function () { + test('switch to showcase tab', async function (assert) { + await selectTab('Showcase'); + await waitForShowcase(); + assert + .dom('[data-test-navigation-reset-button="showcase"]') + .exists(`"Catalog Home" button should exist`) + .hasClass('is-selected'); + assert.dom('[data-test-boxel-radio-option-id="grid"]').doesNotExist(); + }); + + test('switch to apps tab', async function (assert) { + await selectTab('Apps'); + await waitForGrid(); + assert + .dom('[data-test-navigation-reset-button="app"]') + .exists(`"All Apps" button should exist`) + .hasClass('is-selected'); + assert.dom('[data-test-boxel-radio-option-id="grid"]').exists(); + }); + }); + + skip('filters', async function () { + test('list view is shown if filters are applied', async function (assert) { + await waitFor('[data-test-filter-search-input]'); + await click('[data-test-filter-search-input]'); + await fillIn('[data-test-filter-search-input]', 'Mortgage'); + // filter by category + await click('[data-test-filter-list-item="All"]'); + // filter by tag + let tagPill = document.querySelector('[data-test-tag-list-pill]'); + if (tagPill) { + await click(tagPill); + } + + await waitUntil(() => { + const cards = document.querySelectorAll( + '[data-test-catalog-list-view]', + ); + return cards.length === 1; + }); + + assert + .dom('[data-test-catalog-list-view]') + .exists( + 'Catalog list view should be visible when filters are applied', + ); + }); + + // TOOD: restore in CS-9083 + skip('should be reset when clicking "Catalog Home" button', async function (assert) { + await waitFor('[data-test-filter-search-input]'); + await click('[data-test-filter-search-input]'); + await fillIn('[data-test-filter-search-input]', 'Mortgage'); + // filter by category + await click('[data-test-filter-list-item="All"]'); + // filter by tag + let tagPill = document.querySelector('[data-test-tag-list-pill]'); + if (tagPill) { + await click(tagPill); + } + + assert + .dom('[data-test-showcase-view]') + .doesNotExist('Should be in list view after applying filter'); + + await click('[data-test-navigation-reset-button="showcase"]'); + + assert + .dom('[data-test-showcase-view]') + .exists( + 'Should return to showcase view after clicking Catalog Home', + ); + + assert + .dom('[data-test-filter-search-input]') + .hasValue('', 'Search input should be cleared'); + assert + .dom('[data-test-filter-list-item].is-selected') + .doesNotExist('No category should be selected after reset'); + assert + .dom('[data-test-tag-list-pill].selected') + .doesNotExist('No tag should be selected after reset'); + }); + + // TODO: restore in CS-9131 + skip('should be reset when clicking "All Apps" button', async function (assert) { + await selectTab('Apps'); + await waitForGrid(); + + await waitFor('[data-test-filter-search-input]'); + await click('[data-test-filter-search-input]'); + await fillIn('[data-test-filter-search-input]', 'Mortgage'); + // filter by category + await click('[data-test-filter-list-item="All"]'); + // filter by tag + let tagPill = document.querySelector('[data-test-tag-list-pill]'); + if (tagPill) { + await click(tagPill); + } + + await click('[data-test-navigation-reset-button="app"]'); + assert + .dom('[data-test-showcase-view]') + .doesNotExist('Should remain in list view, not return to showcase'); + await waitUntil(() => { + const cards = document.querySelectorAll( + '[data-test-catalog-list-view]', + ); + return cards.length === 1; + }); + assert + .dom('[data-test-catalog-list-view]') + .exists('Catalog list view should still be visible'); + + assert + .dom('[data-test-filter-search-input]') + .hasValue('', 'Search input should be cleared'); + assert + .dom('[data-test-filter-list-item].is-selected') + .doesNotExist('No category should be selected after reset'); + assert + .dom('[data-test-tag-list-pill].selected') + .doesNotExist('No tag should be selected after reset'); + }); + + skip('updates the card count correctly when filtering by a sphere group', async function (assert) { + await click('[data-test-boxel-filter-list-button="LIFE"]'); + assert + .dom('[data-test-cards-grid-cards] [data-test-cards-grid-item]') + .exists({ count: 2 }); + }); + + skip('updates the card count correctly when filtering by a category', async function (assert) { + await click('[data-test-filter-list-item="LIFE"] .dropdown-toggle'); + await click( + '[data-test-boxel-filter-list-button="Health & Wellness"]', + ); + assert + .dom('[data-test-cards-grid-cards] [data-test-cards-grid-item]') + .exists({ count: 1 }); + }); + + skip('updates the card count correctly when filtering by a search input', async function (assert) { + await click('[data-test-filter-search-input]'); + await fillIn('[data-test-filter-search-input]', 'Mortgage'); + await waitUntil(() => { + const cards = document.querySelectorAll( + '[data-test-cards-grid-cards] [data-test-cards-grid-item]', + ); + return cards.length === 1; + }); + assert + .dom('[data-test-cards-grid-cards] [data-test-cards-grid-item]') + .exists({ count: 1 }); + }); + + test('updates the card count correctly when filtering by a single tag', async function (assert) { + await click(`[data-test-tag-list-pill="${gameTagId}"]`); + assert + .dom(`[data-test-tag-list-pill="${gameTagId}"]`) + .hasClass('selected'); + assert + .dom('[data-test-cards-grid-cards] [data-test-cards-grid-item]') + .exists({ count: 1 }); + }); + + test('updates the card count correctly when filtering by multiple tags', async function (assert) { + await click(`[data-test-tag-list-pill="${calculatorTagId}"]`); + await click(`[data-test-tag-list-pill="${gameTagId}"]`); + assert + .dom('[data-test-cards-grid-cards] [data-test-cards-grid-item]') + .exists({ count: 2 }); + }); + + test('updates the card count correctly when multiple filters are applied together', async function (assert) { + await click('[data-test-boxel-filter-list-button="All"]'); + await click(`[data-test-tag-list-pill="${gameTagId}"]`); + await click('[data-test-filter-search-input]'); + await fillIn('[data-test-filter-search-input]', 'Blackjack'); + + await waitUntil(() => { + const cards = document.querySelectorAll( + '[data-test-cards-grid-cards] [data-test-cards-grid-item]', + ); + return cards.length === 1; + }); + + assert + .dom('[data-test-cards-grid-cards] [data-test-cards-grid-item]') + .exists({ count: 1 }); + }); + + test('shows zero results when filtering with a non-matching or invalid search input', async function (assert) { + await click('[data-test-filter-search-input]'); + await fillIn('[data-test-filter-search-input]', 'asdfasdf'); + await waitUntil(() => { + const cards = document.querySelectorAll('[data-test-no-results]'); + return cards.length === 1; + }); + + assert.dom('[data-test-no-results]').exists(); + }); + + test('categories with null sphere fields are excluded from filter list', async function (assert) { + // Setup: Create a category with null sphere field + await setupAcceptanceTestRealm({ + realmURL: mockCatalogURL, + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'Category/category-with-null-sphere.json': { + data: { + type: 'card', + attributes: { + name: 'CategoryWithNullSphere', + }, + relationships: { + sphere: { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${mockCatalogURL}catalog-app/listing/category`, + name: 'Category', + }, + }, + }, + }, + }, + }); + + await visitOperatorMode({ + stacks: [ + [ + { + id: `${mockCatalogURL}`, + format: 'isolated', + }, + ], + ], + }); + + assert + .dom( + '[data-test-boxel-filter-list-button="CategoryWithNullSphere"]', + ) + .doesNotExist( + 'Category with null sphere should not appear in filter list', + ); + }); + }); + }); + }); + + module('listing isolated', function (hooks) { + hooks.beforeEach(async function () { + await visitOperatorMode({ + stacks: [ + [ + { + id: authorListingId, + format: 'isolated', + }, + ], + ], + }); + }); + + test('listing card shows more options dropdown in stack item', async function (assert) { + let triggerSelector = `[data-test-stack-card="${authorListingId}"] [data-test-more-options-button]`; + await waitFor(triggerSelector); + await click(triggerSelector); + await waitFor('[data-test-boxel-dropdown-content]'); + assert + .dom('[data-test-boxel-dropdown-content] [data-test-boxel-menu-item]') + .exists('Listing card dropdown renders menu items'); + assert + .dom( + `[data-test-boxel-dropdown-content] [data-test-boxel-menu-item-text="Generate Example with AI"]`, + ) + .exists('Generate Example with AI action is present'); + }); + + test('after clicking "Remix" button, current realm (particularly catalog realm) is never displayed in realm options', async function (assert) { + let selector = `[data-test-card="${authorListingId}"] [data-test-catalog-listing-action="Remix"]`; + await openMenu(selector, false); + assert + .dom('[data-test-boxel-dropdown-content] [data-test-boxel-menu-item]') + .exists({ count: 1 }); + await assertDropdownItem(assert, 'Test Workspace B'); + await assertDropdownItem(assert, 'Test Workspace A', false); + }); + + test('after clicking "Use Skills" button, the skills is attached to the skill menu', async function (assert) { + await visitOperatorMode({ + stacks: [ + [ + { + id: pirateSkillListingId, + format: 'isolated', + }, + ], + ], + }); + await click( + '[data-test-catalog-listing-embedded-add-skills-to-room-button]', + ); + + await waitFor('[data-room-settled]'); + await click('[data-test-skill-menu][data-test-pill-menu-button]'); + await waitFor('[data-test-skill-menu]'); + assert.dom('[data-test-skill-menu]').exists('Skill menu is visible'); + assert + .dom('[data-test-pill-menu-item]') + .containsText('Talk Like a Pirate') + .exists('Skill is attached to the skill menu'); + }); + + test('after clicking "Remix" button, the ai room is initiated, and prompt is given correctly', async function (assert) { + await verifyListingAction( + assert, + `[data-test-card="${authorListingId}"] [data-test-catalog-listing-action="Remix"]`, + 'Remix', + 'Remix done! Please suggest two example prompts on how to edit this card.', + 'Test Workspace B', + false, + ); + }); + + test('after clicking "Preview" button, the first example card opens up onto the stack', async function (assert) { + await click( + `[data-test-card="${authorListingId}"] [data-test-catalog-listing-embedded-preview-button]`, + ); + await waitForCardOnStack(`${mockCatalogURL}author/Author/example`); + assert + .dom( + `[data-test-stack-card="${mockCatalogURL}author/Author/example"] [data-test-boxel-card-header-title]`, + ) + .hasText('Author - Mike Dane'); + }); + + test('display of sections when viewing listing details', async function (assert) { + await visitOperatorMode({ + stacks: [ + [ + { + id: authorListingId, + format: 'isolated', + }, + ], + ], + }); + + assert + .dom('[data-test-catalog-listing-embedded-summary-section]') + .containsText('A card for representing an author'); + + // Publisher (rendered in header) + assert + .dom('[data-test-app-listing-header-publisher]') + .containsText('By Boxel Publishing'); + + assert + .dom('[data-test-catalog-listing-embedded-license-section]') + .containsText('MIT License'); + + assert + .dom('[data-test-catalog-listing-embedded-images-section]') + .exists({ count: 1 }); + assert + .dom('[data-test-catalog-listing-embedded-examples-section]') + .exists(); + + assert + .dom('[data-test-catalog-listing-embedded-examples] li') + .exists({ count: 1 }); + assert.dom('[data-test-catalog-listing-embedded-tags-section]').exists(); + + //TODO: this assertion is wrong, there is some issue with rendering of specType + // also the format of the isolated has some weird css behaviour like Examples title running out of position + // assert + // .dom('[data-test-catalog-listing-embedded-specs-section]') + // .containsText('Unknown'); + + assert.dom('[data-test-catalog-listing-embedded-specs-section]').exists(); + + assert + .dom('[data-test-catalog-listing-embedded-tags-section]') + .containsText('Calculator'); + assert + .dom('[data-test-catalog-listing-embedded-categories-section]') + .containsText('Writing'); + }); + + test('listing with spec that has a missing specType groups it under unknown (accordion assertion)', async function (assert) { + const unknownListingId = `${mockCatalogURL}Listing/unknown-only`; + await visitOperatorMode({ + stacks: [ + [ + { + id: unknownListingId, + format: 'isolated', + }, + ], + ], + }); + + assert + .dom( + '[data-test-catalog-listing-embedded-specs-section] [data-test-accordion-item]', + ) + .exists({ count: 1 }); + assert + .dom( + '[data-test-catalog-listing-embedded-specs-section] [data-test-accordion-item="unknown"]', + ) + .exists('Unknown group item exists'); + + assert + .dom( + '[data-test-catalog-listing-embedded-specs-section] [data-test-accordion-item="unknown"]', + ) + .containsText('unknown (1)'); + }); + + test('unknown-only listing shows all default fallback texts', async function (assert) { + const unknownListingId = `${mockCatalogURL}Listing/unknown-only`; + await visitOperatorMode({ + stacks: [ + [ + { + id: unknownListingId, + format: 'isolated', + }, + ], + ], + }); + + assert + .dom('[data-test-catalog-listing-embedded-summary-section]') + .containsText('No Summary Provided'); + assert + .dom('[data-test-catalog-listing-embedded-license-section]') + .containsText('No License Provided'); + assert + .dom('[data-test-catalog-listing-embedded-images-section]') + .containsText('No Images Provided'); + assert + .dom('[data-test-catalog-listing-embedded-examples-section]') + .containsText('No Examples Provided'); + assert + .dom('[data-test-catalog-listing-embedded-categories-section]') + .containsText('No Categories Provided'); + assert + .dom('[data-test-catalog-listing-embedded-tags-section]') + .containsText('No Tags Provided'); + assert + .dom('[data-test-catalog-listing-embedded-skills-section]') + .containsText('No Skills Provided'); + }); + + test('remix button does not exist when a listing has no specs', async function (assert) { + await visitOperatorMode({ + stacks: [ + [ + { + id: emptyListingId, + format: 'isolated', + }, + ], + ], + }); + assert + .dom('[data-test-catalog-listing-embedded-specs-section]') + .containsText('No Specs Provided'); + assert.dom('[data-test-catalog-listing-action="Remix"]').doesNotExist(); + }); + + test('remix button does not exist when a skill listing has no skills', async function (assert) { + const emptySkillListingId = incompleteSkillListingId; + await visitOperatorMode({ + stacks: [ + [ + { + id: emptySkillListingId, + format: 'isolated', + }, + ], + ], + }); + assert + .dom('[data-test-catalog-listing-embedded-skills-section]') + .containsText('No Skills Provided'); + assert.dom('[data-test-catalog-listing-action="Remix"]').doesNotExist(); + }); + + test('after clicking "Build" button, the ai room is initiated, and prompt is given correctly', async function (assert) { + await visitOperatorMode({ + stacks: [ + [ + { + id: apiDocumentationStubListingId, + format: 'isolated', + }, + ], + ], + }); + await verifyListingAction( + assert, + `[data-test-card="${apiDocumentationStubListingId}"] [data-test-catalog-listing-action="Build"]`, + 'Build', + 'Generate .gts card definition for "API Documentation" implementing all requirements from the attached listing specification. Then preview the final code in playground panel.', + 'Test Workspace B', + false, + ); + }); + }); + + 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'); + }); + }); + module('"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 { + return url.endsWith('/') && url.length > 1 ? url.slice(0, -1) : url; +} diff --git a/tests/acceptance/real-catalog-app-test.gts b/tests/acceptance/real-catalog-app-test.gts new file mode 100644 index 0000000..03130bf --- /dev/null +++ b/tests/acceptance/real-catalog-app-test.gts @@ -0,0 +1,67 @@ +import { getOwner } from '@ember/owner'; +import { visit, waitFor, waitUntil } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, skip } from 'qunit'; + +import { ensureTrailingSlash } from '@cardstack/runtime-common'; + +import ENV from '@cardstack/host/config/environment'; +import HostModeService from '@cardstack/host/services/host-mode-service'; + +import { setupLocalIndexing } from '../helpers'; +import { setupApplicationTest } from '../helpers/setup'; + +const catalogRealmURL = ensureTrailingSlash(ENV.resolvedCatalogRealmURL); +const CATALOG_READINESS_URL = `${catalogRealmURL}_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson`; + +class StubHostModeService extends HostModeService { + override get isActive() { + return true; + } + + override get hostModeOrigin() { + return 'http://localhost:4201'; + } +} + +module('Acceptance | Catalog | real catalog app', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + + hooks.beforeEach(function () { + getOwner(this)!.register('service:host-mode-service', StubHostModeService); + }); + + // CS-9919 - Skipping this test for now as the catalog realm is now setup only in + // part for speed in host tests. + skip('visiting /catalog/ renders the catalog index card', async function (assert) { + let realmServer = getService('realm-server'); + await realmServer.ready; + await ensureCatalogRealmReady(); + + await visit('/catalog/'); + + await waitFor('[data-test-catalog-app]', { timeout: 30_000 }); + assert.dom('[data-test-card-error]').doesNotExist(); + assert.dom('[data-test-catalog-app]').exists(); + }); +}); + +async function ensureCatalogRealmReady() { + let network = getService('network'); + await waitUntil( + async () => { + try { + let response = await network.fetch(CATALOG_READINESS_URL); + return response.ok; + } catch (e) { + return false; + } + }, + { + timeout: 30_000, + timeoutMessage: `Timed out waiting for catalog realm readiness at ${CATALOG_READINESS_URL}`, + }, + ); +} diff --git a/tests/integration/commands/set-user-system-card-test.gts b/tests/integration/commands/set-user-system-card-test.gts new file mode 100644 index 0000000..efd865f --- /dev/null +++ b/tests/integration/commands/set-user-system-card-test.gts @@ -0,0 +1,63 @@ +import { getOwner } from '@ember/owner'; +import type { RenderingTestContext } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import SetUserSystemCardCommand from '@cardstack/host/commands/set-user-system-card'; +import RealmService from '@cardstack/host/services/realm'; + +import { + setupIntegrationTestRealm, + setupLocalIndexing, + testRealmInfo, + testRealmURL, +} from '../../helpers'; + +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupRenderingTest } from '../../helpers/setup'; + +class StubRealmService extends RealmService { + get defaultReadableRealm() { + return { + path: testRealmURL, + info: testRealmInfo, + }; + } +} + +module('Integration | commands | set-user-system-card', function (hooks) { + setupRenderingTest(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + }); + + hooks.beforeEach(function (this: RenderingTestContext) { + getOwner(this)!.register('service:realm', StubRealmService); + }); + + hooks.beforeEach(async function () { + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + }); + }); + + test('sets the system card account data', async function (assert) { + let commandService = getService('command-service'); + let command = new SetUserSystemCardCommand(commandService.commandContext); + + let systemCardId = 'http://localhost:4201/catalog/SystemCard/default'; + + await command.execute({ + cardId: systemCardId, + }); + + assert.deepEqual(mockMatrixUtils.getSystemCardAccountData(), { + id: systemCardId, + }); + }); +}); diff --git a/tests/integration/listing-update-specs-test.gts b/tests/integration/listing-update-specs-test.gts new file mode 100644 index 0000000..de57c9c --- /dev/null +++ b/tests/integration/listing-update-specs-test.gts @@ -0,0 +1,185 @@ +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { + baseRealm, + SupportedMimeType, + type Realm, +} from '@cardstack/runtime-common'; +import type { Loader } from '@cardstack/runtime-common/loader'; + +import ListingUpdateSpecsCommand from '@cardstack/host/commands/listing-update-specs'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; + +import { + setupCardLogs, + setupIntegrationTestRealm, + setupLocalIndexing, + setupRealmServerEndpoints, + testRealmURL, +} from '../helpers'; +import { setupBaseRealm } from '../helpers/base-realm'; +import { setupMockMatrix } from '../helpers/mock-matrix'; +import { setupRenderingTest } from '../helpers/setup'; + +import type { Listing } from '@cardstack/catalog/listing/listing'; + +module('Integration | commands | listing-update-specs', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + const modulePath = `${testRealmURL}listing-example.gts`; + const exampleCardId = `${testRealmURL}Example/1`; + let loader: Loader; + let testRealm: Realm; + + setupRealmServerEndpoints(hooks, [ + { + route: '_dependencies', + getResponse: async function () { + return new Response( + JSON.stringify({ + data: [ + { + type: 'dependencies', + id: exampleCardId, + attributes: { + canonicalUrl: exampleCardId, + realmUrl: testRealmURL, + entryType: 'instance', + hasError: false, + dependencies: [modulePath], + }, + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': SupportedMimeType.JSONAPI }, + }, + ); + }, + }, + { + route: '_request-forward', + getResponse: async function (req: Request) { + const body = await req.json(); + if (body.url === 'https://openrouter.ai/api/v1/chat/completions') { + return new Response( + JSON.stringify({ + choices: [ + { + message: { + content: 'stubbed readme', + }, + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ error: 'Unknown endpoint' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + }, + }, + ]); + + setupCardLogs( + hooks, + async () => await loader.import(`${baseRealm.url}card-api`), + ); + + let mockMatrixUtils = setupMockMatrix(hooks); + + hooks.beforeEach(function () { + loader = getService('loader-service').loader; + }); + + hooks.beforeEach(async function () { + ({ realm: testRealm } = await setupIntegrationTestRealm({ + mockMatrixUtils, + realmURL: testRealmURL, + contents: { + 'listing-example.gts': ` + import { CardDef } from 'https://cardstack.com/base/card-api'; + + export class ExampleCard extends CardDef { + static displayName = 'Example Card'; + } + `, + 'Example/1.json': { + data: { + id: `${testRealmURL}Example/1`, + type: 'card', + meta: { + adoptsFrom: { + module: '../listing-example', + name: 'ExampleCard', + }, + }, + }, + }, + }, + })); + await getService('realm').login(testRealmURL); + }); + + test('updates specs when a module adds a new export', async function (assert) { + let commandService = getService('command-service'); + let listingUpdateSpecsCommand = new ListingUpdateSpecsCommand( + commandService.commandContext, + ); + + let store = getService('store'); + + let ListingClass = ( + (await loader.import( + '@cardstack/catalog/catalog-app/listing/listing', + )) as { + Listing: typeof Listing; + } + ).Listing; + let listing = (await store.add(new ListingClass(), { + realm: testRealmURL, + })) as InstanceType; + let exampleCard = (await store.get(`${testRealmURL}Example/1`)) as CardDef; + (listing as Listing).examples = [exampleCard]; + + let result = await listingUpdateSpecsCommand.execute({ listing }); + let specNames = result.specs.map((spec) => spec.ref?.name).filter(Boolean); + assert.deepEqual(specNames, ['ExampleCard'], 'initial spec is created'); + + await testRealm.write( + 'listing-example.gts', + ` + import { CardDef } from 'https://cardstack.com/base/card-api'; + + export class ExampleCard extends CardDef { + static displayName = 'Example Card'; + } + + export class AnotherCard extends CardDef { + static displayName = 'Another Card'; + } + `, + ); + getService('loader-service').resetLoader({ + reason: 'refresh module exports', + }); + + result = await listingUpdateSpecsCommand.execute({ listing }); + specNames = result.specs + .map((spec) => spec.ref?.name) + .filter(Boolean) + .sort(); + assert.deepEqual( + specNames, + ['AnotherCard', 'ExampleCard'], + 'new export is reflected in specs', + ); + }); +}); diff --git a/tests/integration/upload-image-test.gts b/tests/integration/upload-image-test.gts new file mode 100644 index 0000000..1327560 --- /dev/null +++ b/tests/integration/upload-image-test.gts @@ -0,0 +1,389 @@ +import { getOwner } from '@ember/owner'; +import type { RenderingTestContext } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import type NetworkService from '@cardstack/host/services/network'; +import RealmService from '@cardstack/host/services/realm'; + +import { + setupIntegrationTestRealm, + setupLocalIndexing, + setupRealmServerEndpoints, + testRealmInfo, + testRealmURL, + SYSTEM_CARD_FIXTURE_CONTENTS, +} from '../helpers'; +import { setupBaseRealm } from '../helpers/base-realm'; +import { setupMockMatrix } from '../helpers/mock-matrix'; +import { setupRenderingTest } from '../helpers/setup'; + +import type UploadImageCommand from '@cardstack/catalog/commands/upload-image'; + +class StubRealmService extends RealmService { + get defaultReadableRealm() { + return { + path: testRealmURL, + info: testRealmInfo, + }; + } +} + +module('Integration | commands | upload-image', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + autostart: true, + }); + + let lastForwardPayload: any; + let forwardPayloads: any[] = []; + let lastDirectUploadRequest: + | { + url: string; + formData: FormData; + } + | undefined; + let networkService: NetworkService; + let directUploadFetchHandler: + | ((request: Request) => Promise) + | undefined; + + const directUploadResponse = { + success: true, + errors: [], + result: { + id: 'direct-upload-id', + uploadURL: 'https://upload.imagedelivery.net/direct-upload-url', + }, + }; + + setupRealmServerEndpoints(hooks, [ + { + route: '_request-forward', + getResponse: async (request: Request) => { + const body = await request.json(); + lastForwardPayload = body; + forwardPayloads.push(body); + + let responsePayload; + if ( + body.url === + 'https://api.cloudflare.com/client/v4/accounts/4a94a1eb2d21bbbe160234438a49f687/images/v2/direct_upload' + ) { + responsePayload = directUploadResponse; + } else { + responsePayload = { + success: true, + errors: [], + result: { + id: 'cloudflare-image-id', + }, + }; + } + + return new Response(JSON.stringify(responsePayload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }, + }, + ]); + + hooks.beforeEach(async function (this: RenderingTestContext) { + getOwner(this)!.register('service:realm', StubRealmService); + lastForwardPayload = undefined; + forwardPayloads = []; + lastDirectUploadRequest = undefined; + directUploadFetchHandler = async (request: Request) => { + if ( + request.url === 'https://upload.imagedelivery.net/direct-upload-url' + ) { + const formData = await request.formData(); + lastDirectUploadRequest = { + url: request.url, + formData, + }; + return new Response( + JSON.stringify({ + success: true, + errors: [], + result: { + id: 'cloudflare-direct-upload-id', + }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + return null; + }; + networkService = getService('network'); + networkService.virtualNetwork.mount(directUploadFetchHandler, { + prepend: true, + }); + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + }, + }); + }); + + hooks.afterEach(function () { + if (directUploadFetchHandler) { + networkService.virtualNetwork.unmount(directUploadFetchHandler); + directUploadFetchHandler = undefined; + } + }); + + test('uploads image via Cloudflare and saves CloudflareImage card', async function (assert) { + assert.expect(7); + + const commandService = getService('command-service'); + const loaderService = getService('loader-service'); + const loader = loaderService.loader; + const UploadImageCommandClass: typeof UploadImageCommand = ( + (await loader.import('@cardstack/catalog/commands/upload-image')) as { + default: typeof UploadImageCommand; + } + ).default; + const command = new UploadImageCommandClass(commandService.commandContext); + + const result = await command.execute({ + sourceImageUrl: 'https://example.com/photo.jpg', + targetRealmUrl: testRealmURL, + }); + + assert.ok(result, 'command returns a result card'); + assert.ok(result.cardId, 'result card contains a card id'); + + assert.strictEqual( + lastForwardPayload.url, + 'https://api.cloudflare.com/client/v4/accounts/4a94a1eb2d21bbbe160234438a49f687/images/v1', + 'requests are forwarded to Cloudflare upload endpoint', + ); + assert.true(lastForwardPayload.multipart, 'request is sent as multipart'); + + const store = getService('store'); + const savedCard = store.peek(result.cardId!); + assert.ok(savedCard, 'saved card can be retrieved from the store'); + assert.strictEqual( + (savedCard as any).cloudflareId, + 'cloudflare-image-id', + 'saved card has expected Cloudflare id', + ); + assert.strictEqual( + (savedCard as any).url, + 'https://i.boxel.site/cloudflare-image-id/public', + 'computed URL uses expected Cloudflare delivery format with Boxel domain', + ); + }); + + test('performs direct upload when source is a blob URL', async function (assert) { + assert.expect(12); + + const commandService = getService('command-service'); + const loaderService = getService('loader-service'); + const loader = loaderService.loader; + const UploadImageCommandClass: typeof UploadImageCommand = ( + (await loader.import('@cardstack/catalog/commands/upload-image')) as { + default: typeof UploadImageCommand; + } + ).default; + const command = new UploadImageCommandClass(commandService.commandContext); + + const fileBlob = new Blob(['fake image bytes'], { type: 'image/png' }); + const file = + typeof File === 'function' + ? new File([fileBlob], 'photo.png', { type: 'image/png' }) + : (Object.assign(fileBlob, { name: 'photo.png' }) as Blob & { + name: string; + }); + const objectUrl = URL.createObjectURL(file); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + let url: string; + if (typeof input === 'string') { + url = input; + } else if (input instanceof URL) { + url = input.href; + } else if (input instanceof Request) { + url = input.url; + } else { + url = String(input); + } + + if (url.startsWith('blob:')) { + return new Response(file, { + status: 200, + headers: { + 'Content-Type': file.type, + }, + }); + } + + return originalFetch(input as RequestInfo, init); + }; + + try { + const result = await command.execute({ + sourceImageUrl: objectUrl, + targetRealmUrl: testRealmURL, + }); + + assert.ok(result, 'command returns a result'); + assert.ok(result.cardId, 'result card contains an id'); + + assert.strictEqual( + forwardPayloads.length, + 1, + 'proxy invoked only for direct upload URL request', + ); + + const [directUploadCall] = forwardPayloads; + assert.strictEqual( + directUploadCall.url, + 'https://api.cloudflare.com/client/v4/accounts/4a94a1eb2d21bbbe160234438a49f687/images/v2/direct_upload', + 'first proxy call requests direct upload URL', + ); + assert.true( + directUploadCall.multipart, + 'direct upload URL request is sent as multipart form-data', + ); + + assert.ok(lastDirectUploadRequest, 'direct upload performed via fetch'); + assert.strictEqual( + lastDirectUploadRequest?.url, + 'https://upload.imagedelivery.net/direct-upload-url', + 'direct upload posts to provided upload URL', + ); + + const fileEntry = lastDirectUploadRequest?.formData.get('file'); + assert.ok(fileEntry, 'form data includes file field'); + const uploadedFile = fileEntry as Blob; + assert.strictEqual(uploadedFile.type, 'image/png'); + + const directUploadPayload = JSON.parse(directUploadCall.requestBody); + assert.false( + directUploadPayload.requireSignedURLs, + 'direct upload request specifies default signing behaviour', + ); + + const expectedBytes = new Uint8Array(await file.arrayBuffer()); + const uploadedBytes = new Uint8Array(await uploadedFile.arrayBuffer()); + assert.deepEqual( + Array.from(uploadedBytes), + Array.from(expectedBytes), + 'uploaded file contents match source blob', + ); + + const store = getService('store'); + const savedCard = store.peek(result.cardId!); + assert.strictEqual( + (savedCard as any).cloudflareId, + 'cloudflare-direct-upload-id', + 'saved card uses id returned by direct upload', + ); + } finally { + globalThis.fetch = originalFetch; + URL.revokeObjectURL(objectUrl); + } + }); + + test('performs direct upload when source is a data URI', async function (assert) { + assert.expect(12); + + const commandService = getService('command-service'); + const loaderService = getService('loader-service'); + const loader = loaderService.loader; + const UploadImageCommandClass: typeof UploadImageCommand = ( + (await loader.import('@cardstack/catalog/commands/upload-image')) as { + default: typeof UploadImageCommand; + } + ).default; + const command = new UploadImageCommandClass(commandService.commandContext); + + const payloadString = 'fake image bytes'; + const nodeBuffer = (globalThis as any).Buffer as + | { + from( + input: string, + encoding?: string, + ): { toString(encoding: string): string }; + } + | undefined; + const base64Payload = nodeBuffer + ? nodeBuffer.from(payloadString, 'utf-8').toString('base64') + : btoa(payloadString); + const dataUri = `data:image/png;base64,${base64Payload}`; + + const result = await command.execute({ + sourceImageUrl: dataUri, + targetRealmUrl: testRealmURL, + }); + + assert.ok(result, 'command returns a result'); + assert.ok(result.cardId, 'result card contains an id'); + + assert.strictEqual( + forwardPayloads.length, + 1, + 'proxy invoked only for direct upload URL request', + ); + + const [directUploadCall] = forwardPayloads; + assert.strictEqual( + directUploadCall.url, + 'https://api.cloudflare.com/client/v4/accounts/4a94a1eb2d21bbbe160234438a49f687/images/v2/direct_upload', + 'proxy call requests direct upload URL', + ); + assert.true( + directUploadCall.multipart, + 'direct upload URL request is sent as multipart form-data', + ); + + assert.ok(lastDirectUploadRequest, 'direct upload performed via fetch'); + assert.strictEqual( + lastDirectUploadRequest?.url, + 'https://upload.imagedelivery.net/direct-upload-url', + 'direct upload posts to provided upload URL', + ); + + const fileEntry = lastDirectUploadRequest?.formData.get('file'); + assert.ok(fileEntry, 'form data includes file field'); + const uploadedFile = fileEntry as File; + assert.strictEqual(uploadedFile.type, 'image/png'); + assert.strictEqual(uploadedFile.name, 'upload.png'); + + const expectedBytes = nodeBuffer + ? new Uint8Array(nodeBuffer.from(payloadString, 'utf-8') as any) + : Uint8Array.from(payloadString, (char) => char.charCodeAt(0)); + const uploadedBytes = new Uint8Array(await uploadedFile.arrayBuffer()); + assert.deepEqual( + Array.from(uploadedBytes), + Array.from(expectedBytes), + 'uploaded file contents match data URI payload', + ); + + const store = getService('store'); + const savedCard = store.peek(result.cardId!); + assert.strictEqual( + (savedCard as any).cloudflareId, + 'cloudflare-direct-upload-id', + 'saved card uses id returned by direct upload', + ); + }); +}); From 6c34d0b5ff18800892e7d3859f9ef419a7c35514 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Thu, 5 Feb 2026 17:25:41 +0800 Subject: [PATCH 2/3] Feedback --- tests/acceptance/catalog-app-test.gts | 24 ++++--- tests/acceptance/real-catalog-app-test.gts | 6 +- .../commands/set-user-system-card-test.gts | 63 ------------------- 3 files changed, 13 insertions(+), 80 deletions(-) delete mode 100644 tests/integration/commands/set-user-system-card-test.gts diff --git a/tests/acceptance/catalog-app-test.gts b/tests/acceptance/catalog-app-test.gts index 9ea064f..1839eef 100644 --- a/tests/acceptance/catalog-app-test.gts +++ b/tests/acceptance/catalog-app-test.gts @@ -8,7 +8,7 @@ import { } from '@ember/test-helpers'; import { getService } from '@universal-ember/test-support'; -import { module, skip, test } from 'qunit'; +import { module, test } from 'qunit'; import { ensureTrailingSlash } from '@cardstack/runtime-common'; @@ -1251,7 +1251,7 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { }); }); - skip('filters', async function () { + module('filters', async function () { test('list view is shown if filters are applied', async function (assert) { await waitFor('[data-test-filter-search-input]'); await click('[data-test-filter-search-input]'); @@ -1278,8 +1278,7 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { ); }); - // TOOD: restore in CS-9083 - skip('should be reset when clicking "Catalog Home" button', async function (assert) { + test('should be reset when clicking "Catalog Home" button', async function (assert) { await waitFor('[data-test-filter-search-input]'); await click('[data-test-filter-search-input]'); await fillIn('[data-test-filter-search-input]', 'Mortgage'); @@ -1313,9 +1312,8 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { .dom('[data-test-tag-list-pill].selected') .doesNotExist('No tag should be selected after reset'); }); - - // TODO: restore in CS-9131 - skip('should be reset when clicking "All Apps" button', async function (assert) { + + test('should be reset when clicking "All Apps" button', async function (assert) { await selectTab('Apps'); await waitForGrid(); @@ -1355,14 +1353,14 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { .doesNotExist('No tag should be selected after reset'); }); - skip('updates the card count correctly when filtering by a sphere group', async function (assert) { + test('updates the card count correctly when filtering by a sphere group', async function (assert) { await click('[data-test-boxel-filter-list-button="LIFE"]'); assert .dom('[data-test-cards-grid-cards] [data-test-cards-grid-item]') .exists({ count: 2 }); }); - skip('updates the card count correctly when filtering by a category', async function (assert) { + test('updates the card count correctly when filtering by a category', async function (assert) { await click('[data-test-filter-list-item="LIFE"] .dropdown-toggle'); await click( '[data-test-boxel-filter-list-button="Health & Wellness"]', @@ -1372,7 +1370,7 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { .exists({ count: 1 }); }); - skip('updates the card count correctly when filtering by a search input', async function (assert) { + test('updates the card count correctly when filtering by a search input', async function (assert) { await click('[data-test-filter-search-input]'); await fillIn('[data-test-filter-search-input]', 'Mortgage'); await waitUntil(() => { @@ -2266,8 +2264,8 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { ); }); }); - skip('"use"', async function () { - skip('card listing', async function (assert) { + module('"use"', async function () { + test('card listing', async function (assert) { const listingName = 'author'; const listingId = mockCatalogURL + 'Listing/author.json'; await executeCommand( @@ -2485,7 +2483,7 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { }); }); - skip('"use" is successful even if target realm does not have a trailing slash', async function (assert) { + test('"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( diff --git a/tests/acceptance/real-catalog-app-test.gts b/tests/acceptance/real-catalog-app-test.gts index 03130bf..3f5ebc5 100644 --- a/tests/acceptance/real-catalog-app-test.gts +++ b/tests/acceptance/real-catalog-app-test.gts @@ -2,7 +2,7 @@ import { getOwner } from '@ember/owner'; import { visit, waitFor, waitUntil } from '@ember/test-helpers'; import { getService } from '@universal-ember/test-support'; -import { module, skip } from 'qunit'; +import { module, test } from 'qunit'; import { ensureTrailingSlash } from '@cardstack/runtime-common'; @@ -33,9 +33,7 @@ module('Acceptance | Catalog | real catalog app', function (hooks) { getOwner(this)!.register('service:host-mode-service', StubHostModeService); }); - // CS-9919 - Skipping this test for now as the catalog realm is now setup only in - // part for speed in host tests. - skip('visiting /catalog/ renders the catalog index card', async function (assert) { + test('visiting /catalog/ renders the catalog index card', async function (assert) { let realmServer = getService('realm-server'); await realmServer.ready; await ensureCatalogRealmReady(); diff --git a/tests/integration/commands/set-user-system-card-test.gts b/tests/integration/commands/set-user-system-card-test.gts deleted file mode 100644 index efd865f..0000000 --- a/tests/integration/commands/set-user-system-card-test.gts +++ /dev/null @@ -1,63 +0,0 @@ -import { getOwner } from '@ember/owner'; -import type { RenderingTestContext } from '@ember/test-helpers'; - -import { getService } from '@universal-ember/test-support'; -import { module, test } from 'qunit'; - -import SetUserSystemCardCommand from '@cardstack/host/commands/set-user-system-card'; -import RealmService from '@cardstack/host/services/realm'; - -import { - setupIntegrationTestRealm, - setupLocalIndexing, - testRealmInfo, - testRealmURL, -} from '../../helpers'; - -import { setupMockMatrix } from '../../helpers/mock-matrix'; -import { setupRenderingTest } from '../../helpers/setup'; - -class StubRealmService extends RealmService { - get defaultReadableRealm() { - return { - path: testRealmURL, - info: testRealmInfo, - }; - } -} - -module('Integration | commands | set-user-system-card', function (hooks) { - setupRenderingTest(hooks); - setupLocalIndexing(hooks); - - let mockMatrixUtils = setupMockMatrix(hooks, { - loggedInAs: '@testuser:localhost', - activeRealms: [testRealmURL], - }); - - hooks.beforeEach(function (this: RenderingTestContext) { - getOwner(this)!.register('service:realm', StubRealmService); - }); - - hooks.beforeEach(async function () { - await setupIntegrationTestRealm({ - mockMatrixUtils, - contents: {}, - }); - }); - - test('sets the system card account data', async function (assert) { - let commandService = getService('command-service'); - let command = new SetUserSystemCardCommand(commandService.commandContext); - - let systemCardId = 'http://localhost:4201/catalog/SystemCard/default'; - - await command.execute({ - cardId: systemCardId, - }); - - assert.deepEqual(mockMatrixUtils.getSystemCardAccountData(), { - id: systemCardId, - }); - }); -}); From fd9e24bdff1effd4af7263b02d2185b152f67b2d Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Mon, 9 Feb 2026 10:33:56 +0800 Subject: [PATCH 3/3] Fix test --- tests/acceptance/catalog-app-test.gts | 122 ++++++++++++++++++--- tests/acceptance/real-catalog-app-test.gts | 3 +- 2 files changed, 106 insertions(+), 19 deletions(-) diff --git a/tests/acceptance/catalog-app-test.gts b/tests/acceptance/catalog-app-test.gts index 1839eef..89af051 100644 --- a/tests/acceptance/catalog-app-test.gts +++ b/tests/acceptance/catalog-app-test.gts @@ -8,7 +8,7 @@ import { } from '@ember/test-helpers'; import { getService } from '@universal-ember/test-support'; -import { module, test } from 'qunit'; +import { module, test, skip } from 'qunit'; import { ensureTrailingSlash } from '@cardstack/runtime-common'; @@ -57,8 +57,12 @@ const themeListingId = `${mockCatalogURL}ThemeListing/cardstack-theme`; const blogPostListingId = `${mockCatalogURL}Listing/blog-post`; //license const mitLicenseId = `${mockCatalogURL}License/mit`; +//sphere +const lifeSphereId = `${mockCatalogURL}Sphere/life`; //category const writingCategoryId = `${mockCatalogURL}Category/writing`; +const healthWellnessCategoryId = `${mockCatalogURL}Category/health-wellness`; +const fitnessCategoryId = `${mockCatalogURL}Category/fitness`; //publisher const publisherId = `${mockCatalogURL}Publisher/boxel-publisher`; @@ -538,7 +542,12 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { relationships: { 'tags.0': { links: { - self: calculatorTagId, + self: gameTagId, + }, + }, + 'categories.0': { + links: { + self: healthWellnessCategoryId, }, }, }, @@ -612,10 +621,10 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { self: pirateSkillId, }, }, - }, - 'categories.0': { - links: { - self: writingCategoryId, + 'categories.0': { + links: { + self: fitnessCategoryId, + }, }, }, meta: { @@ -626,6 +635,20 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { }, }, }, + 'Sphere/life.json': { + data: { + type: 'card', + attributes: { + name: 'LIFE', + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/sphere`, + name: 'Sphere', + }, + }, + }, + }, 'Category/writing.json': { data: { type: 'card', @@ -640,6 +663,48 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { }, }, }, + 'Category/health-wellness.json': { + data: { + type: 'card', + attributes: { + name: 'Health & Wellness', + }, + relationships: { + sphere: { + links: { + self: lifeSphereId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/category`, + name: 'Category', + }, + }, + }, + }, + 'Category/fitness.json': { + data: { + type: 'card', + attributes: { + name: 'Fitness', + }, + relationships: { + sphere: { + links: { + self: lifeSphereId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/category`, + name: 'Category', + }, + }, + }, + }, 'Listing/incomplete-skill.json': { data: { type: 'card', @@ -1251,7 +1316,7 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { }); }); - module('filters', async function () { + module('filters', function () { test('list view is shown if filters are applied', async function (assert) { await waitFor('[data-test-filter-search-input]'); await click('[data-test-filter-search-input]'); @@ -1312,7 +1377,7 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { .dom('[data-test-tag-list-pill].selected') .doesNotExist('No tag should be selected after reset'); }); - + test('should be reset when clicking "All Apps" button', async function (assert) { await selectTab('Apps'); await waitForGrid(); @@ -1354,7 +1419,9 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { }); test('updates the card count correctly when filtering by a sphere group', async function (assert) { + await waitFor('[data-test-boxel-filter-list-button="LIFE"]'); await click('[data-test-boxel-filter-list-button="LIFE"]'); + assert .dom('[data-test-cards-grid-cards] [data-test-cards-grid-item]') .exists({ count: 2 }); @@ -1370,15 +1437,27 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { .exists({ count: 1 }); }); - test('updates the card count correctly when filtering by a search input', async function (assert) { + // TODO: fix in CS-10157 + skip('updates the card count correctly when filtering by a search input', async function (assert) { + await selectTab('Cards'); + await waitForGrid(); + // Wait for specific cards to be fully rendered before searching + // This ensures prerendering has completed + await waitForCardOnGrid(authorListingId, 'Author'); + await waitForCardOnGrid(personListingId, 'Person'); await click('[data-test-filter-search-input]'); - await fillIn('[data-test-filter-search-input]', 'Mortgage'); - await waitUntil(() => { - const cards = document.querySelectorAll( - '[data-test-cards-grid-cards] [data-test-cards-grid-item]', - ); - return cards.length === 1; - }); + await fillIn('[data-test-filter-search-input]', 'Author'); + await waitFor('[data-test-catalog-list-view]'); + await settled(); + await waitUntil( + () => { + const cards = document.querySelectorAll( + '[data-test-cards-grid-cards] [data-test-cards-grid-item]', + ); + return cards.length === 1; + }, + { timeout: 15000 }, + ); assert .dom('[data-test-cards-grid-cards] [data-test-cards-grid-item]') .exists({ count: 1 }); @@ -2029,6 +2108,12 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { }, targetRealm: testDestinationRealmURL, }); + await settled(); + // Wait for all background indexing and save operations to complete + let store = getService('store'); + await store.flushSaves(); + await store.loaded(); + await settled(); // 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'); @@ -2107,6 +2192,7 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { // 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 settled(); await waitForCardOnStack(persistedId); assert .dom( @@ -2264,7 +2350,7 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { ); }); }); - module('"use"', async function () { + module('"use"', function () { test('card listing', async function (assert) { const listingName = 'author'; const listingId = mockCatalogURL + 'Listing/author.json'; @@ -2501,7 +2587,7 @@ module('Acceptance | Catalog | catalog app tests', function (hooks) { listingName, ); - let instanceFolder = `${outerFolder}Author`; + let instanceFolder = `${outerFolder}Author/`; await openDir(assert, instanceFolder); await verifyJSONWithUUIDInFolder(assert, instanceFolder); }); diff --git a/tests/acceptance/real-catalog-app-test.gts b/tests/acceptance/real-catalog-app-test.gts index 3f5ebc5..ca2f460 100644 --- a/tests/acceptance/real-catalog-app-test.gts +++ b/tests/acceptance/real-catalog-app-test.gts @@ -9,7 +9,7 @@ import { ensureTrailingSlash } from '@cardstack/runtime-common'; import ENV from '@cardstack/host/config/environment'; import HostModeService from '@cardstack/host/services/host-mode-service'; -import { setupLocalIndexing } from '../helpers'; +import { setupLocalIndexing, setupAuthEndpoints } from '../helpers'; import { setupApplicationTest } from '../helpers/setup'; const catalogRealmURL = ensureTrailingSlash(ENV.resolvedCatalogRealmURL); @@ -30,6 +30,7 @@ module('Acceptance | Catalog | real catalog app', function (hooks) { setupLocalIndexing(hooks); hooks.beforeEach(function () { + setupAuthEndpoints(); getOwner(this)!.register('service:host-mode-service', StubHostModeService); });