diff --git a/packages/base/command.gts b/packages/base/command.gts index 5c183af9c5..645011c6d7 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -72,6 +72,7 @@ export class ShowCardInput extends CardDef { export class SwitchSubmodeInput extends CardDef { @field submode = contains(StringField); + @field codePath = contains(StringField); } export class CreateModuleInput extends CardDef { diff --git a/packages/host/app/commands/switch-submode.ts b/packages/host/app/commands/switch-submode.ts index 0a8a7800d2..c297f2ecfd 100644 --- a/packages/host/app/commands/switch-submode.ts +++ b/packages/host/app/commands/switch-submode.ts @@ -44,11 +44,15 @@ export default class SwitchSubmodeCommand extends HostBaseCommand< this.operatorModeStateService.updateCodePath(null); break; case Submodes.Code: - this.operatorModeStateService.updateCodePath( - this.lastCardInRightMostStack - ? new URL(this.lastCardInRightMostStack.id + '.json') - : null, - ); + if (input.codePath) { + this.operatorModeStateService.updateCodePath(new URL(input.codePath)); + } else { + this.operatorModeStateService.updateCodePath( + this.lastCardInRightMostStack + ? new URL(this.lastCardInRightMostStack.id + '.json') + : null, + ); + } break; default: throw new Error(`invalid submode specified: ${input.submode}`); diff --git a/packages/host/app/components/operator-mode/card-error-detail.gts b/packages/host/app/components/operator-mode/card-error-detail.gts new file mode 100644 index 0000000000..99be8630fc --- /dev/null +++ b/packages/host/app/components/operator-mode/card-error-detail.gts @@ -0,0 +1,113 @@ +import { fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { service } from '@ember/service'; + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import TriangleAlert from '@cardstack/boxel-icons/triangle-alert'; + +import { dropTask } from 'ember-concurrency'; +import perform from 'ember-concurrency/helpers/perform'; + +import { Accordion, Button } from '@cardstack/boxel-ui/components'; + +import SwitchSubmodeCommand from '../../commands/switch-submode'; +import { type CardError } from '../../resources/card-resource'; + +import type CommandService from '../../services/command-service'; + +interface Signature { + Args: { + error: CardError['errors'][0]; + title?: string; + }; +} + +export default class CardErrorDetail extends Component { + @tracked private showErrorDetail = false; + @service private declare commandService: CommandService; + + private toggleDetail = () => (this.showErrorDetail = !this.showErrorDetail); + + private viewInCodeMode = dropTask(async () => { + let switchSubmodeCommand = new SwitchSubmodeCommand( + this.commandService.commandContext, + ); + const InputType = await switchSubmodeCommand.getInputType(); + let input = new InputType({ + submode: 'code', + codePath: `${this.args.error.id}.json`, + }); + await switchSubmodeCommand.execute(input); + }); + + +} diff --git a/packages/host/app/components/operator-mode/card-error.gts b/packages/host/app/components/operator-mode/card-error.gts new file mode 100644 index 0000000000..095a425ae1 --- /dev/null +++ b/packages/host/app/components/operator-mode/card-error.gts @@ -0,0 +1,31 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; + +import FileAlert from '@cardstack/boxel-icons/file-alert'; + +const CardErrorComponent: TemplateOnlyComponent = ; + +export default CardErrorComponent; diff --git a/packages/host/app/components/operator-mode/code-submode.gts b/packages/host/app/components/operator-mode/code-submode.gts index 6a3056f7b1..9e69c424b5 100644 --- a/packages/host/app/components/operator-mode/code-submode.gts +++ b/packages/host/app/components/operator-mode/code-submode.gts @@ -32,7 +32,6 @@ import { type ResolvedCodeRef, PermissionsContextName, } from '@cardstack/runtime-common'; -import { SerializedError } from '@cardstack/runtime-common/error'; import { isEquivalentBodyPosition } from '@cardstack/runtime-common/schema-analysis-plugin'; import RecentFiles from '@cardstack/host/components/editor/recent-files'; @@ -311,57 +310,6 @@ export default class CodeSubmode extends Component { return null; } - private get fileErrorMessages(): string[] { - if (this.isCard) { - if (this.cardResource.cardError) { - try { - let error = this.cardResource.cardError.error; - - if (error.responseText) { - let parsedError = JSON.parse(error.responseText); - - // handle instance errors - if (parsedError.errors.find((e: any) => e.message)) { - return parsedError.errors.map((e: any) => e.message); - } - - // otherwise handle module errors - let allDetails = parsedError.errors - .concat( - ...parsedError.errors.map( - (e: SerializedError) => e.additionalErrors, - ), - ) - .map((e: SerializedError) => e.detail); - - // There’s often a pair of errors where one has an unhelpful prefix like this: - // cannot return card from index: Not Found - http://test-realm/test/non-card not found - // http://test-realm/test/non-card not found - - let detailsWithoutDuplicateSuffixes = allDetails.reduce( - (details: string[], currentDetail: string) => { - return [ - ...details.filter( - (existingDetail) => !existingDetail.endsWith(currentDetail), - ), - currentDetail, - ]; - }, - [], - ); - - return detailsWithoutDuplicateSuffixes; - } - } catch (e) { - console.log('Error extracting card preview errors', e); - return []; - } - } - } - - return []; - } - private get currentOpenFile() { return this.operatorModeStateService.openFile.current; } @@ -854,12 +802,10 @@ export default class CodeSubmode extends Component {
- {{#each this.fileErrorMessages as |error|}} -
{{error}}
- {{/each}} +
{{this.cardResource.cardError.message}}
{{else if this.fileIncompatibilityMessage}} diff --git a/packages/host/app/components/operator-mode/interact-submode.gts b/packages/host/app/components/operator-mode/interact-submode.gts index b833617603..a25ab8b15f 100644 --- a/packages/host/app/components/operator-mode/interact-submode.gts +++ b/packages/host/app/components/operator-mode/interact-submode.gts @@ -341,9 +341,13 @@ export default class InteractSubmode extends Component { } private close = task(async (item: StackItem) => { - let { card, request } = item; // close the item first so user doesn't have to wait for the save to complete this.operatorModeStateService.trimItemsFromStack(item); + if (item.cardError) { + return; + } + + let { card, request } = item; // only save when closing a stack item in edit mode. there should be no unsaved // changes in isolated mode because they were saved when user toggled between diff --git a/packages/host/app/components/operator-mode/stack-item.gts b/packages/host/app/components/operator-mode/stack-item.gts index 0c6f14090d..fb50183420 100644 --- a/packages/host/app/components/operator-mode/stack-item.gts +++ b/packages/host/app/components/operator-mode/stack-item.gts @@ -35,7 +35,7 @@ import { import { MenuItem, getContrastColor } from '@cardstack/boxel-ui/helpers'; import { cssVar, optional } from '@cardstack/boxel-ui/helpers'; -import { IconTrash, IconLink } from '@cardstack/boxel-ui/icons'; +import { IconTrash, IconLink, Warning } from '@cardstack/boxel-ui/icons'; import { type Actions, @@ -59,13 +59,18 @@ import type { FieldType, } from 'https://cardstack.com/base/card-api'; +import { htmlComponent } from '../../lib/html-component'; import ElementTracker from '../../resources/element-tracker'; import Preview from '../preview'; +import CardError from './card-error'; +import CardErrorDetail from './card-error-detail'; + import OperatorModeOverlays from './overlays'; import type CardService from '../../services/card-service'; import type EnvironmentService from '../../services/environment-service'; +import type LoaderService from '../../services/loader-service'; import type OperatorModeStateService from '../../services/operator-mode-state-service'; import type RealmService from '../../services/realm'; @@ -116,6 +121,7 @@ export default class OperatorModeStackItem extends Component { @service private declare environmentService: EnvironmentService; @service private declare operatorModeStateService: OperatorModeStateService; @service private declare realm: RealmService; + @service private declare loaderService: LoaderService; // @tracked private selectedCards = new TrackedArray([]); @tracked private selectedCards = new TrackedArray([]); @@ -320,20 +326,67 @@ export default class OperatorModeStackItem extends Component { return this.args.item.card; } + @cached + get cardError() { + return this.args.item.cardError; + } + + @cached + get lastKnownGoodHtml() { + if (this.cardError?.meta.lastKnownGoodHtml) { + this.loadScopedCSS.perform(); + return htmlComponent(this.cardError.meta.lastKnownGoodHtml); + } + return undefined; + } + + @cached + get cardErrorSummary() { + if (!this.cardError) { + return undefined; + } + return this.cardError.status === 404 && + // a missing link error looks a lot like a missing card error + this.cardError.message.includes('missing') + ? `Link Not Found` + : this.cardError.title; + } + + get cardErrorTitle() { + if (!this.cardError) { + return undefined; + } + return `Card Error: ${this.cardErrorSummary}`; + } + private loadCard = restartableTask(async () => { await this.args.item.ready(); }); + private loadScopedCSS = restartableTask(async () => { + if (this.cardError?.meta.scopedCssUrls) { + await Promise.all( + this.cardError.meta.scopedCssUrls.map((cssModuleUrl) => + this.loaderService.loader.import(cssModuleUrl), + ), + ); + } + }); + private subscribeToCard = task(async () => { await this.args.item.ready(); - this.subscribedCard = this.card; - let api = this.args.item.api; - registerDestructor(this, this.cleanup); - api.subscribeToChanges(this.subscribedCard, this.onCardChange); - this.refreshSaveMsg = setInterval( - () => this.calculateLastSavedMsg(), - 10 * 1000, - ) as unknown as number; + // TODO how do we make sure that this is called after the error is cleared? + // Address this as part of SSE support for card errors + if (!this.cardError) { + this.subscribedCard = this.card; + let api = this.args.item.api; + registerDestructor(this, this.cleanup); + api.subscribeToChanges(this.subscribedCard, this.onCardChange); + this.refreshSaveMsg = setInterval( + () => this.calculateLastSavedMsg(), + 10 * 1000, + ) as unknown as number; + } }); private cleanup = () => { @@ -550,6 +603,44 @@ export default class OperatorModeStackItem extends Component { Loading card... + {{else if this.cardError}} + +
+ {{#if this.lastKnownGoodHtml}} + + {{else}} + + {{/if}} +
+ {{else}} {{#let (this.realm.info this.card.id) as |realmInfo|}} { justify: center; align-items: center; } + .card-error { + opacity: 0.4; + border-radius: 0; + box-shadow: none; + overflow: auto; + } } diff --git a/packages/host/app/lib/stack-item.ts b/packages/host/app/lib/stack-item.ts index fbcfacd1ce..c41e2ce09d 100644 --- a/packages/host/app/lib/stack-item.ts +++ b/packages/host/app/lib/stack-item.ts @@ -93,7 +93,7 @@ export class StackItem { } get cardError() { - return this.cardResource?.cardError?.error; + return this.cardResource?.cardError; } get isWideFormat() { diff --git a/packages/host/app/resources/card-resource.ts b/packages/host/app/resources/card-resource.ts index a5e3b2ee70..03e7dc94e9 100644 --- a/packages/host/app/resources/card-resource.ts +++ b/packages/host/app/resources/card-resource.ts @@ -10,6 +10,8 @@ import { restartableTask } from 'ember-concurrency'; import { task } from 'ember-concurrency'; import { Resource } from 'ember-resources'; +import status from 'statuses'; + import { Loader, isSingleCardDocument, @@ -18,8 +20,6 @@ import { hasExecutableExtension, } from '@cardstack/runtime-common'; -import { ErrorDetails } from '@cardstack/runtime-common/error'; - import type MessageService from '@cardstack/host/services/message-service'; import type { CardDef } from 'https://cardstack.com/base/card-api'; @@ -29,9 +29,19 @@ import type * as CardAPI from 'https://cardstack.com/base/card-api'; import type CardService from '../services/card-service'; import type LoaderService from '../services/loader-service'; -interface CardError { - id: string; - error: ErrorDetails; +export interface CardError { + errors: { + id: string; + status: number; + title: string; + message: string; + realm: string | undefined; + meta: { + lastKnownGoodHtml: string | null; + scopedCssUrls: string[]; + stack: string | null; + }; + }[]; } interface Args { @@ -71,7 +81,7 @@ const realmSubscriptions: Map< export class CardResource extends Resource { url: string | undefined; @tracked loaded: Promise | undefined; - @tracked cardError: CardError | undefined; + @tracked cardError: CardError['errors'][0] | undefined; @tracked private _card: CardDef | undefined; @tracked private _api: typeof CardAPI | undefined; @tracked private staleCard: CardDef | undefined; @@ -167,10 +177,7 @@ export class CardResource extends Resource { let card = await this.getCard(url); if (!card) { if (this.cardError) { - console.warn( - `cannot load card ${this.cardError.id}`, - this.cardError.error, - ); + console.warn(`cannot load card ${this.cardError.id}`, this.cardError); } this.clearCardInstance(); return; @@ -201,6 +208,7 @@ export class CardResource extends Resource { return; } realmSubscribers.set(this, { + // TODO figure out how to go in an out of errors via SSE unsubscribe: this.messageService.subscribe( realmURL.href, ({ type, data: dataStr }) => { @@ -262,14 +270,57 @@ export class CardResource extends Resource { ); return card; } catch (error: any) { - this.cardError = { - id: url.href, - error, - }; + let errorResponse: CardError; + try { + errorResponse = JSON.parse(error.responseText) as CardError; + } catch (parseError) { + switch (error.status) { + // tailor HTTP responses as necessary for better user feedback + case 404: + errorResponse = { + errors: [ + { + id: url.href, + status: 404, + title: 'Card Not Found', + message: `The card ${url.href} does not exist`, + realm: error.responseHeaders?.get('X-Boxel-Realm-Url'), + meta: { + lastKnownGoodHtml: null, + scopedCssUrls: [], + stack: null, + }, + }, + ], + }; + break; + default: + errorResponse = { + errors: [ + { + id: url.href, + status: error.status, + title: status.message[error.status] ?? `HTTP ${error.status}`, + message: `Received HTTP ${error.status} from server ${ + error.responseText ?? '' + }`.trim(), + realm: error.responseHeaders?.get('X-Boxel-Realm-Url'), + meta: { + lastKnownGoodHtml: null, + scopedCssUrls: [], + stack: null, + }, + }, + ], + }; + } + } + this.cardError = errorResponse.errors[0]; return; } } + // TODO deal with live update of card that goes into and out of an error state private reload = task(async (card: CardDef) => { try { await this.cardService.reloadCard(card); diff --git a/packages/host/app/resources/stack-backgrounds.ts b/packages/host/app/resources/stack-backgrounds.ts index 6dbad1eba6..6ea6f7124b 100644 --- a/packages/host/app/resources/stack-backgrounds.ts +++ b/packages/host/app/resources/stack-backgrounds.ts @@ -5,6 +5,7 @@ import { Resource } from 'ember-resources'; import type { Stack } from '../components/operator-mode/interact-submode'; import type CardService from '../services/card-service'; +import type RealmService from '../services/realm'; interface Args { positional: [stacks: Stack[]]; @@ -13,6 +14,7 @@ interface Args { export class StackBackgroundsResource extends Resource { @tracked value: (string | undefined | null)[] = []; @service declare cardService: CardService; + @service declare realm: RealmService; get backgroundImageURLs() { return this.value?.map((u) => (u ? u : undefined)) ?? []; @@ -46,6 +48,14 @@ export class StackBackgroundsResource extends Resource { } let bottomMostStackItem = stack[0]; await bottomMostStackItem.ready; + if (bottomMostStackItem.cardError) { + let realm = bottomMostStackItem.cardError.realm; + if (!realm) { + return undefined; + } + await this.realm.ensureRealmMeta(realm); + return this.realm.info(realm)?.backgroundURL; + } return (await this.cardService.getRealmInfo(bottomMostStackItem.card)) ?.backgroundURL; }), diff --git a/packages/host/package.json b/packages/host/package.json index 39c60e5ee4..117d1c1a40 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -71,6 +71,7 @@ "@types/qs": "^6.9.17", "@types/qunit": "^2.11.3", "@types/rsvp": "^4.0.9", + "@types/statuses": "^2.0.5", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^7.9.0", "@typescript-eslint/parser": "^7.9.0", @@ -164,6 +165,7 @@ "qunit-dom": "^2.0.0", "safe-stable-stringify": "^2.4.3", "start-server-and-test": "^1.14.0", + "statuses": "^2.0.1", "stream-browserify": "^3.0.0", "super-fast-md5": "^1.0.1", "testem": "3.10.1", diff --git a/packages/host/tests/acceptance/commands-test.gts b/packages/host/tests/acceptance/commands-test.gts index 8268e28b15..4edd6eac0d 100644 --- a/packages/host/tests/acceptance/commands-test.gts +++ b/packages/host/tests/acceptance/commands-test.gts @@ -324,6 +324,9 @@ module('Acceptance | Commands tests', function (hooks) { submode: { type: 'string', }, + codePath: { + type: 'string', + }, title: { type: 'string', }, @@ -429,6 +432,9 @@ module('Acceptance | Commands tests', function (hooks) { submode: { type: 'string', }, + codePath: { + type: 'string', + }, title: { type: 'string', }, diff --git a/packages/host/tests/integration/components/operator-mode-test.gts b/packages/host/tests/integration/components/operator-mode-test.gts index 01c92b5f4e..c840975e64 100644 --- a/packages/host/tests/integration/components/operator-mode-test.gts +++ b/packages/host/tests/integration/components/operator-mode-test.gts @@ -16,7 +16,12 @@ import { module, test } from 'qunit'; import { FieldContainer } from '@cardstack/boxel-ui/components'; -import { baseRealm, Deferred, Realm } from '@cardstack/runtime-common'; +import { + baseRealm, + Deferred, + LooseSingleCardDocument, + Realm, +} from '@cardstack/runtime-common'; import { Loader } from '@cardstack/runtime-common/loader'; import CardPrerender from '@cardstack/host/components/card-prerender'; @@ -100,6 +105,29 @@ module('Integration | operator-mode', function (hooks) { let { CardsGrid } = cardsGrid; let { CatalogEntry } = catalogEntry; + // use string source so we can get the transpiled scoped CSS + let friendWithCSSSource = ` + import { Component, field, contains, linksTo, CardDef, StringField } from 'https://cardstack.com/base/card-api'; + export class FriendWithCSS extends CardDef { + static displayName = 'Friend'; + @field friend = linksTo(() => FriendWithCSS); + static isolated = class Isolated extends Component { + + }; + } + `; + class Pet extends CardDef { static displayName = 'Pet'; @field name = contains(StringField); @@ -353,6 +381,7 @@ module('Integration | operator-mode', function (hooks) { 'blog-post.gts': { BlogPost }, 'author.gts': { Author }, 'friend.gts': { Friend }, + 'friend-with-css.gts': friendWithCSSSource, 'publishing-packet.gts': { PublishingPacket }, 'pet-room.gts': { PetRoom }, 'Pet/mango.json': petMango, @@ -381,6 +410,59 @@ module('Integration | operator-mode', function (hooks) { name: 'Friend A', friend: friendB, }), + 'FriendWithCSS/friend-b.json': { + data: { + attributes: { + title: 'Jade', + }, + meta: { + adoptsFrom: { + module: '../friend-with-css.gts', + name: 'FriendWithCSS', + }, + }, + }, + } as LooseSingleCardDocument, + 'FriendWithCSS/friend-a.json': { + data: { + attributes: { + title: 'Hassan', + }, + relationships: { + friend: { + links: { + self: './friend-b', + }, + }, + }, + meta: { + adoptsFrom: { + module: '../friend-with-css.gts', + name: 'FriendWithCSS', + }, + }, + }, + } as LooseSingleCardDocument, + 'FriendWithCSS/missing-link.json': { + data: { + attributes: { + title: 'Boris', + }, + relationships: { + friend: { + links: { + self: './does-not-exist', + }, + }, + }, + meta: { + adoptsFrom: { + module: '../friend-with-css.gts', + name: 'FriendWithCSS', + }, + }, + }, + } as LooseSingleCardDocument, 'grid.json': new CardsGrid(), 'CatalogEntry/publishing-packet.json': new CatalogEntry({ title: 'Publishing Packet', @@ -465,6 +547,125 @@ module('Integration | operator-mode', function (hooks) { assert.dom('[data-test-stack-card-index="1"]').includesText('Mango'); }); + test('it renders a card with an error that has does not have a last known good state', async function (assert) { + await setCardInOperatorModeState( + `${testRealmURL}FriendWithCSS/missing-link`, + ); + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + + assert + .dom('[data-test-boxel-card-header-title]') + .includesText('Link Not Found', 'card error title is displayed'); + assert.dom('[data-test-error-title]').containsText('Link Not Found'); + await click('[data-test-error-detail-toggle] button'); + assert + .dom('[data-test-error-detail]') + .containsText( + `missing file ${testRealmURL}FriendWithCSS/does-not-exist.json`, + ); + assert + .dom('[data-test-error-stack]') + .containsText('at CurrentRun.visitFile'); + assert.strictEqual( + operatorModeStateService.state?.submode, + 'interact', + 'in interact mode', + ); + await click('[data-test-view-in-code-mode-button]'); + assert.strictEqual( + operatorModeStateService.state?.submode, + 'code', + 'in code mode', + ); + assert.strictEqual( + operatorModeStateService.state?.codePath?.href, + `${testRealmURL}FriendWithCSS/missing-link.json`, + 'codePath is correct', + ); + }); + + test('it renders a card with an error that has a last known good state', async function (assert) { + await testRealm.write( + 'FriendWithCSS/friend-a.json', + JSON.stringify({ + data: { + type: 'card', + attributes: { + name: 'Friend A', + }, + relationships: { + friend: { + links: { + self: './does-not-exist', + }, + }, + }, + meta: { + adoptsFrom: { + module: '../friend-with-css.gts', + name: 'FriendWithCSS', + }, + }, + }, + } as LooseSingleCardDocument), + ); + await setCardInOperatorModeState(`${testRealmURL}FriendWithCSS/friend-a`); + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + + assert + .dom('[data-test-boxel-card-header-title]') + .includesText('Link Not Found', 'card error title is displayed'); + assert + .dom('[data-test-card-error]') + .includesText( + 'Hassan has a friend Jade', + 'the last known good HTML is rendered', + ); + + // use percy snapshot to ensure the CSS has been applied--a red color + await percySnapshot(assert); + + await click('[data-test-error-detail-toggle] button'); + assert + .dom('[data-test-error-detail]') + .containsText( + `missing file ${testRealmURL}FriendWithCSS/does-not-exist.json`, + ); + assert + .dom('[data-test-error-stack]') + .containsText('at CurrentRun.visitFile'); + assert.strictEqual( + operatorModeStateService.state?.submode, + 'interact', + 'in interact mode', + ); + await click('[data-test-view-in-code-mode-button]'); + assert.strictEqual( + operatorModeStateService.state?.submode, + 'code', + 'in code mode', + ); + assert.strictEqual( + operatorModeStateService.state?.codePath?.href, + `${testRealmURL}FriendWithCSS/friend-a.json`, + 'codePath is correct', + ); + }); + test('it auto saves the field value', async function (assert) { assert.expect(7); await setCardInOperatorModeState(`${testRealmURL}Person/fadhlan`); @@ -583,7 +784,7 @@ module('Integration | operator-mode', function (hooks) { assert.dom('[data-test-pet]').includesText('Paper Bad cat!'); }); - test('a 403 from Web Appliction Firewall is handled gracefully when auto-saving', async function (assert) { + test('a 403 from Web Application Firewall is handled gracefully when auto-saving', async function (assert) { let networkService = this.owner.lookup('service:network') as NetworkService; networkService.virtualNetwork.mount( async (req: Request) => { @@ -622,21 +823,21 @@ module('Integration | operator-mode', function (hooks) { document .querySelector('[data-test-auto-save-indicator]') ?.textContent?.trim() == 'Saving…', - { timeoutMessage: 'Waitng for Saving... to appear' }, + { timeoutMessage: 'Waiting for Saving... to appear' }, ); await waitUntil( () => document .querySelector('[data-test-auto-save-indicator]') ?.textContent?.trim() == 'Failed to save: Rejected by firewall', - { timeoutMessage: 'Waitng for "Failed to save" to appear' }, + { timeoutMessage: 'Waiting for "Failed to save" to appear' }, ); assert .dom('[data-test-auto-save-indicator]') .containsText('Failed to save: Rejected by firewall'); }); - test('opens workspace chooser after closing the only remainingcard on the stack', async function (assert) { + test('opens workspace chooser after closing the only remaining card on the stack', async function (assert) { await setCardInOperatorModeState(`${testRealmURL}Person/fadhlan`); await renderComponent( @@ -2766,7 +2967,7 @@ module('Integration | operator-mode', function (hooks) { assert .dom(`[data-test-cards-grid-item="${testRealmURL}CardDef/1"]`) .exists(); - assert.dom(`[data-test-boxel-filter-list-button]`).exists({ count: 8 }); + assert.dom(`[data-test-boxel-filter-list-button]`).exists({ count: 9 }); assert.dom(`[data-test-boxel-filter-list-button="Skill"]`).doesNotExist(); await click('[data-test-create-new-card-button]'); @@ -2786,7 +2987,7 @@ module('Integration | operator-mode', function (hooks) { await click('[data-test-close-button]'); }, }); - assert.dom(`[data-test-boxel-filter-list-button]`).exists({ count: 9 }); + assert.dom(`[data-test-boxel-filter-list-button]`).exists({ count: 10 }); assert.dom(`[data-test-boxel-filter-list-button="Skill"]`).exists(); await click('[data-test-boxel-filter-list-button="Skill"]'); @@ -2802,7 +3003,7 @@ module('Integration | operator-mode', function (hooks) { }, }); - assert.dom(`[data-test-boxel-filter-list-button]`).exists({ count: 8 }); + assert.dom(`[data-test-boxel-filter-list-button]`).exists({ count: 9 }); assert.dom(`[data-test-boxel-filter-list-button="Skill"]`).doesNotExist(); assert .dom(`[data-test-boxel-filter-list-button="All Cards"]`) diff --git a/packages/realm-server/tests/realm-server-test.ts b/packages/realm-server/tests/realm-server-test.ts index 9864c7db2a..863e2bfc81 100644 --- a/packages/realm-server/tests/realm-server-test.ts +++ b/packages/realm-server/tests/realm-server-test.ts @@ -662,6 +662,7 @@ module('Realm Server', function (hooks) { status: 404, title: 'Not Found', message: `missing file ${testRealmHref}does-not-exist.json`, + realm: testRealmHref, meta: { lastKnownGoodHtml: null, scopedCssUrls: [], diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index fa81c6edd2..9866cc65bc 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -1405,6 +1405,9 @@ export class Realm { status: maybeError.error.errorDetail.status, title: maybeError.error.errorDetail.title, message: maybeError.error.errorDetail.detail, + // note that this is actually available as part of the response + // header too--it's just easier for clients when it is here + realm: this.url, meta: { lastKnownGoodHtml: maybeError.error.lastKnownGoodHtml, scopedCssUrls: maybeError.error.scopedCssUrls, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee7c1bb03c..1838713fc7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1353,6 +1353,9 @@ importers: '@types/rsvp': specifier: ^4.0.9 version: 4.0.9 + '@types/statuses': + specifier: ^2.0.5 + version: 2.0.5 '@types/uuid': specifier: ^9.0.8 version: 9.0.8 @@ -1632,6 +1635,9 @@ importers: start-server-and-test: specifier: ^1.14.0 version: 1.14.0 + statuses: + specifier: ^2.0.1 + version: 2.0.1 stream-browserify: specifier: ^3.0.0 version: 3.0.0 @@ -7392,6 +7398,10 @@ packages: resolution: {integrity: sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==} dev: true + /@types/statuses@2.0.5: + resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==} + dev: true + /@types/stream-chain@2.0.1: resolution: {integrity: sha512-D+Id9XpcBpampptkegH7WMsEk6fUdf9LlCIX7UhLydILsqDin4L0QT7ryJR0oycwC7OqohIzdfcMHVZ34ezNGg==} dependencies: