Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions packages/matrix/tests/publish-realm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,184 @@ test.describe('Publish realm', () => {
).toBeVisible();
});

test('republishing reflects updated source content on the published URL (CS-11043)', async ({
page,
}) => {
// CS-11043 regression net. The bug was: a republish reported success
// server-side but the published URL kept serving the previous publish's
// rendered HTML, sometimes for tens of hours. Every existing
// publish-realm test does exactly one publish — this is the gap the
// bug slipped through. Here we publish, change content, publish
// again, and assert the published URL shows the new content (and not
// the old).

await clearLocalStorage(page, serverIndexUrl);
user = await createSubscribedUserAndLogin(
page,
'publish-realm',
serverIndexUrl,
);

let serverURL = new URL(serverIndexUrl);
let defaultRealmURL = `${serverURL.protocol}//${serverURL.host}/${user.username}/new-workspace/`;

await createRealm(page, 'new-workspace', '1New Workspace');

// Define a card type whose isolated template renders a single
// sentinel string we can grep for in the published HTML.
await postCardSource(
page,
defaultRealmURL,
'sentinel-card.gts',
`
import { CardDef, Component, field, contains } from "https://cardstack.com/base/card-api";
import StringField from "https://cardstack.com/base/string";

export class SentinelCard extends CardDef {
@field value = contains(StringField);

static isolated = class extends Component<typeof this> {
<template>
<div data-test-sentinel-output>{{@model.value}}</div>
</template>
};
}
`,
);

// Initial index.json: an instance of SentinelCard carrying the
// sentinel that we expect the first publish to render.
let initialSentinel = `sentinel-initial-${Date.now()}`;
await postCardSource(
page,
defaultRealmURL,
'index.json',
JSON.stringify(
{
data: {
type: 'card',
attributes: { value: initialSentinel },
meta: {
adoptsFrom: { module: './sentinel-card', name: 'SentinelCard' },
},
},
},
null,
2,
),
);

// Open the publish modal and do the first publish.
await page.locator('[data-test-workspace="1New Workspace"]').click();
await page.locator('[data-test-submode-switcher] button').click();
await page.locator('[data-test-boxel-menu-item-text="Host"]').click();
await page.locator('[data-test-publish-realm-button]').click();
await page.locator('[data-test-default-domain-checkbox]').click();
await page.locator('[data-test-publish-button]').click();
await page.waitForSelector('[data-test-unpublish-button]');

// Open the published URL and verify the initial sentinel renders.
let firstTabPromise = page.waitForEvent('popup');
await page
.locator(
'[data-test-publish-realm-modal] [data-test-open-boxel-space-button]',
)
.click();
let firstTab = await firstTabPromise;
await firstTab.waitForLoadState();
await expect(firstTab.locator('[data-test-sentinel-output]')).toHaveText(
initialSentinel,
{ timeout: 30_000 },
);
await firstTab.close();
await page.bringToFront();

// Close the modal so we can re-open it cleanly for the second publish.
await page.locator('[data-test-close-modal]').click();

// Change the index card's sentinel value. This is the "user edits
// their realm between publishes" step.
let updatedSentinel = `sentinel-updated-${Date.now()}`;
await postCardSource(
page,
defaultRealmURL,
'index.json',
JSON.stringify(
{
data: {
type: 'card',
attributes: { value: updatedSentinel },
meta: {
adoptsFrom: { module: './sentinel-card', name: 'SentinelCard' },
},
},
},
null,
2,
),
);

// Re-open the publish modal. The default-domain checkbox is still
// there (the realm appears as already-published in the modal); the
// publish button is what we re-click to push the new content.
await page.locator('[data-test-publish-realm-button]').click();
// The publish handler awaits sourceRealm.indexing() before doing the
// copy, so we don't need a manual settle here — the click below
// serializes behind any pending incremental indexing on the source.
let publishButton = page.locator('[data-test-publish-button]');
// Set up the network wait BEFORE clicking — the handler awaits the
// full reindex before returning 202, so when this resolves we
// know the publish is fully done. Real-world republishes can sit
// behind a from-scratch that takes a few minutes on contended CI;
// 3 minutes is the safe upper bound. We `.catch()` the wait so a
// transient hiccup (Playwright losing the response, the page
// refresh-bouncing, etc.) downgrades to null rather than
// throwing — the published-URL assertion below is the
// load-bearing check either way.
let publishResponsePromise = page
.waitForResponse(
(r) =>
r.url().endsWith('/_publish-realm') &&
r.request().method() === 'POST',
{ timeout: 180_000 },
)
.catch(() => null);
await publishButton.click();
let publishResponse = await publishResponsePromise;
if (publishResponse) {
expect(
publishResponse.status(),
'second publish should succeed',
).toBeLessThan(300);
}

// Open the published URL again and verify the UPDATED sentinel
// renders — and the initial sentinel does NOT. This is the
// assertion CS-11043 would have failed: the old test only checked
// for "card visible", which stays true even when serving stale
// content.
let secondTabPromise = page.waitForEvent('popup');
await page
.locator(
'[data-test-publish-realm-modal] [data-test-open-boxel-space-button]',
)
.click();
let secondTab = await secondTabPromise;
await secondTab.waitForLoadState();
// Generous retry budget: if waitForResponse above was downgraded
// to null, the publish may not yet be done by the time we land on
// the published URL. The assertion retries until the sentinel
// appears or this budget expires, which gives slow republishes
// room to land without flapping the test.
await expect(secondTab.locator('[data-test-sentinel-output]')).toHaveText(
updatedSentinel,
{ timeout: 120_000 },
);
await expect(secondTab.locator('body')).not.toContainText(initialSentinel);
await secondTab.close();
await page.bringToFront();
});

test('open site popover opens with shift-click', async ({ page }) => {
await publishDefaultRealm(page);

Expand Down
14 changes: 14 additions & 0 deletions packages/runtime-common/realm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3373,6 +3373,20 @@ export class Realm {
let createdAt = await this.getCreatedTime(handle.path);
let defaultHeaders: Record<string, string> = {
'content-type': inferContentType(handle.path),
// CS-11043. The publish-republish failure mode was Chromium's
// process-level HTTP cache holding stale module bytes across
// publishes — the realm-server previously sent these
// responses with no `Cache-Control` and no `Last-Modified`,
// so Chromium applied heuristic caching and reused old bytes
// even after the on-disk file changed under a republish.
// `no-store` evicts the heuristic-cache vector entirely:
// every source/module fetch from the puppeteer page (and
// any other HTTP consumer) goes back to the realm-server,
// which then serves whichever bytes are current on EFS.
// Cost: no browser cache reuse for unchanged files, but
// these are typically prerendered into `boxel_index.isolated_html`
// by the indexer and not re-fetched per page view anyway.
'cache-control': 'no-store',
...(createdAt != null
? { 'x-created': formatRFC7231(createdAt * 1000) }
: {}),
Expand Down
Loading