diff --git a/packages/base/helpers/clock.ts b/packages/base/helpers/clock.ts index 6e4318b18d8..67afcd01e56 100644 --- a/packages/base/helpers/clock.ts +++ b/packages/base/helpers/clock.ts @@ -1,48 +1,7 @@ // The instant card code measures elapsed time from. // -// Left alone this is the real clock. Setting a number on `globalThis.__boxelNow` -// pins it, so what a card renders becomes a function of its own data rather -// than of when it happened to be rendered. -// -// The number is epoch **milliseconds**, the same units `Date.now()` returns. -// Worth stating because this package stamps a file's `lastModified` and -// `resourceCreatedAt` in epoch *seconds*, and `toDate` in -// `file-formats/file-presentation` exists to promote those — so seconds are -// the value nearest to hand for someone reaching for a pin. A seconds-valued -// pin lands in 1970, puts every real timestamp in its future, and turns every -// age into an absolute date through the `days < 0` branch. It throws nothing -// and produces no NaN; a snapshot diff reads it as a formatting change. -// -// `now()` deliberately does not promote seconds the way `toDate` does. A file -// mtime below the year-2001 millisecond floor is unambiguous, but a pin is -// chosen by its caller, and guessing would hide the mistake rather than -// surface it. -// -// That distinction matters for anything whose output is an age — `3d ago`, a -// countdown, an "expires soon" warning, a `Today` / `Yesterday` day header. -// Each changes on a schedule nobody chose, so a visual comparison of one -// differs between two runs over identical data, and the usual way to quiet -// that is to stop comparing the element at all. Pinning the clock instead -// keeps the value visible, so a change in how it is formatted still registers. -// -// Reading through here is what makes a renderer pinnable; a renderer that -// calls the clock directly is not, whatever this module says. The rule that -// keeps them converted lives with the lint config rather than in this comment, -// because a list of callers here would be wrong the moment one is added. -// -// Read off `globalThis` rather than taken as an argument because it has to -// reach card code through whichever loader instance rendered it, which no call -// site knows about. `__boxelRenderContext` and its neighbours already work -// this way. -export function now(): number { - let pinned = (globalThis as { __boxelNow?: unknown }).__boxelNow; - return typeof pinned === 'number' && Number.isFinite(pinned) - ? pinned - : Date.now(); -} - -// The same instant as a `Date`, for callers that would otherwise write -// `new Date()`. -export function nowDate(): Date { - return new Date(now()); -} +// The implementation is in `@cardstack/runtime-common`, because the host app +// measures the same timestamps and needs the same instant; this is the path +// card code imports it by. See that module for what the pin is, what units it +// takes, and why it is read off a global. +export { now, nowDate } from '@cardstack/runtime-common'; diff --git a/packages/host/app/resources/last-modified-date.ts b/packages/host/app/resources/last-modified-date.ts index 84037d9a595..b27ccfcdb4e 100644 --- a/packages/host/app/resources/last-modified-date.ts +++ b/packages/host/app/resources/last-modified-date.ts @@ -4,6 +4,8 @@ import { tracked } from '@glimmer/tracking'; import { formatDistance } from 'date-fns'; import { Resource } from 'ember-modify-based-class-resource'; +import { now as clockNow } from '@cardstack/runtime-common'; + import type { Ready as ReadyFile } from '@cardstack/host/resources/file'; interface Args { @@ -12,7 +14,10 @@ interface Args { export const LAST_SAVED_JUST_NOW_THRESHOLD_MS = 60 * 1000; -export function formatLastSavedText(date: Date, now = Date.now()) { +// Defaults to the shared clock rather than the real one: a test that pins the +// instant also pins the timestamps its fixtures carry, and measuring those +// against the wall clock reports a file saved in the future. +export function formatLastSavedText(date: Date, now = clockNow()) { if (Math.abs(now - date.getTime()) < LAST_SAVED_JUST_NOW_THRESHOLD_MS) { return 'Last saved just now'; } diff --git a/packages/host/tests/helpers/adapter.ts b/packages/host/tests/helpers/adapter.ts index f9659d233f4..20251182065 100644 --- a/packages/host/tests/helpers/adapter.ts +++ b/packages/host/tests/helpers/adapter.ts @@ -27,6 +27,7 @@ import type { } from '@cardstack/runtime-common/realm'; import { WebMessageStream, messageCloseHandler } from './stream'; +import { createFixtureMtimeSequence } from './test-clock'; import { createJWT, testRealmURL } from '.'; @@ -65,6 +66,8 @@ export class TestRealmAdapter implements RealmAdapter { #subscriber: ((message: FileWatcherEventContent) => void) | undefined; #loader: Loader | undefined; // Will be set in the realm's constructor - needed for openFile for shimming purposes #ready = new Deferred(); + // Per adapter, so each realm's files stay inside the just-now window. + #nextMtime = createFixtureMtimeSequence(); #potentialModulesAndInstances: { content: any; url: URL }[] = []; #mockMatrixUtils: MockUtils; @@ -80,7 +83,7 @@ export class TestRealmAdapter implements RealmAdapter { this.#paths = new RealmPaths(realmURL); this.#mockMatrixUtils = mockMatrixUtils; - let now = unixTime(Date.now()); + let now = this.#nextMtime(); for (let [path, content] of Object.entries(contents)) { let segments = path.split('/'); @@ -311,7 +314,7 @@ export class TestRealmAdapter implements RealmAdapter { let updateEvent: FileWatcherEventContent; - let lastModified = unixTime(Date.now()); + let lastModified = this.#nextMtime(); this.#lastModified.set(this.#paths.fileURL(path).href, lastModified); if (dir.contents[name]) { diff --git a/packages/host/tests/helpers/test-clock.ts b/packages/host/tests/helpers/test-clock.ts new file mode 100644 index 00000000000..2d5f6b0e03c --- /dev/null +++ b/packages/host/tests/helpers/test-clock.ts @@ -0,0 +1,61 @@ +// The instant the suite reports as "now". +// +// Card code reads the clock through `@cardstack/base/helpers/clock`, which +// falls through to the real one unless something pins it. Pinning it here +// makes what a card renders a function of its own data: "3d ago", a countdown, +// an age and an "expires soon" warning all stop depending on when the suite +// happened to run, so a visual comparison of them is a comparison of the code +// rather than a race against whichever threshold each is nearest. +// +// The value is arbitrary — determinism is the point, not realism — but it is +// not free of consequences, so it is chosen rather than picked. Two things +// follow from it: +// +// Realms the tests build in the browser stamp their files with this same +// instant (see `adapter.ts`), so those files read as `today`, which is what +// they read as when both sides were the real clock. Nothing about them moves. +// +// Realms served from the index cache carry mtimes that +// `scripts/normalize-realm-mtimes.mjs` derives from file content, so they are +// fixed dates scattered across decades. Their rendered ages are measured from +// this instant instead of from today, which shifts some of them by a unit the +// first time this lands. That is a one-time baseline change, and it buys the +// property that they never shift again on their own. +// Mid-month and mid-day on purpose. A calendar test asserts that days earlier +// in the current month are disabled by a `today` sentinel, and guards itself +// with `getDate() > 1` — pinned to the first, that assertion would skip rather +// than fail, which is a quieter way to lose it than leaving it broken. +export const TEST_CLOCK_INSTANT = Date.UTC(2026, 8, 15, 12, 0, 0); + +export function pinTestClock() { + (globalThis as { __boxelNow?: number }).__boxelNow = TEST_CLOCK_INSTANT; +} + +// A realm's fixture mtimes, as a fresh sequence per adapter. +// +// Two constraints pull against each other here. +// +// They must advance: the indexer decides what a from-scratch pass has to +// revisit by comparing a file's mtime against the one on its index row and +// skipping where they match, so a write that leaves the mtime alone is an edit +// it cannot see. +// +// They must also stay within a minute of the pinned instant, because +// `formatLastSavedText` calls anything closer than that "just now" and the +// inspector asserts a seeded fixture reads that way — which it did when both +// the clock and the stamp were the real one, the files having been written +// moments earlier. +// +// A minute of one-second steps is only sixty values, which a whole shard would +// exhaust immediately. Per adapter it is ample: a realm seeds once and a test +// writes a handful of times. Sequences in different realms overlap, which +// costs nothing — the comparison that matters is between a file and its own +// index row. +const FIXTURE_MTIME_SPAN_S = 59; + +export function createFixtureMtimeSequence(): () => number { + let pinnedSeconds = Math.floor(TEST_CLOCK_INSTANT / 1000); + let step = 0; + return () => + Math.min(pinnedSeconds - FIXTURE_MTIME_SPAN_S + step++, pinnedSeconds - 1); +} diff --git a/packages/host/tests/integration/date-time-fields-test.gts b/packages/host/tests/integration/date-time-fields-test.gts index 0ab3b4f0a32..f7b3fcf0d17 100644 --- a/packages/host/tests/integration/date-time-fields-test.gts +++ b/packages/host/tests/integration/date-time-fields-test.gts @@ -26,6 +26,7 @@ import { buildField, } from '../helpers/field-test-helpers'; import { setupRenderingTest } from '../helpers/setup'; +import { TEST_CLOCK_INSTANT } from '../helpers/test-clock'; module('Integration | date-time fields', function (hooks) { setupRenderingTest(hooks); @@ -485,7 +486,10 @@ module('Integration | date-time fields', function (hooks) { `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; // Anchor on mid-month days so all asserted cells are guaranteed to be // rendered in the calendar pane that centers on the current month. - let now = new Date(); + // The same instant the calendar measures from: the field reads the clock + // through the seam, so a test that asked the real clock what "today" is + // would be asserting against a different day than the component rendered. + let now = new Date(TEST_CLOCK_INSTANT); let day14 = new Date(now.getFullYear(), now.getMonth(), 14); let day15 = new Date(now.getFullYear(), now.getMonth(), 15); let day16 = new Date(now.getFullYear(), now.getMonth(), 16); diff --git a/packages/host/tests/test-helper.js b/packages/host/tests/test-helper.js index 5a40afe693d..ba21b53a9cb 100644 --- a/packages/host/tests/test-helper.js +++ b/packages/host/tests/test-helper.js @@ -8,11 +8,17 @@ import { start as examStart } from 'ember-exam/test-support'; // eslint-disable-next-line ember/no-test-import-export import { loadRealmTests } from './live-test'; import { setupQUnit } from './helpers/setup-qunit'; +import { pinTestClock } from './helpers/test-clock'; import { registerShardWarmup } from './helpers/shard-warmup'; import { selectShardModules } from './helpers/shard-modules'; import testModuleTimings from './test-module-timings.json'; export async function start(examOptions) { + // Before anything renders: card code reads the clock through a seam, and + // pinning it here is what stops a rendered elapsed time depending on when + // the suite ran. + pinTestClock(); + // ember-basic-dropdown 9 has no boot-time initializer, and in a test // build it prefers the page-level wormhole div, which sits outside the // test root where scoped DOM helpers cannot see teleported content. diff --git a/packages/host/tests/unit/file-view-model-test.ts b/packages/host/tests/unit/file-view-model-test.ts index 45590d494fc..7cdeac479a7 100644 --- a/packages/host/tests/unit/file-view-model-test.ts +++ b/packages/host/tests/unit/file-view-model-test.ts @@ -430,11 +430,17 @@ module('Unit | file-formats', function (hooks) { const PINNED = 1_768_478_400; const DAY = 86_400; + // Restored rather than deleted: the suite pins the clock once at + // startup, so deleting it here would drop every later test in the shard + // back onto the wall clock while their fixtures still carry pinned + // timestamps. + let suitePin: number | undefined; hooks.beforeEach(function () { + suitePin = (globalThis as { __boxelNow?: number }).__boxelNow; (globalThis as { __boxelNow?: number }).__boxelNow = PINNED * 1000; }); hooks.afterEach(function () { - delete (globalThis as { __boxelNow?: number }).__boxelNow; + (globalThis as { __boxelNow?: number }).__boxelNow = suitePin; }); test('measures from the pinned instant rather than the real clock', function (assert) { diff --git a/packages/runtime-common/clock.ts b/packages/runtime-common/clock.ts new file mode 100644 index 00000000000..57ba2884d25 --- /dev/null +++ b/packages/runtime-common/clock.ts @@ -0,0 +1,49 @@ +// The instant anything measuring elapsed time should measure from. +// +// Unset, this is the real clock. Setting a number on `globalThis.__boxelNow` +// pins it, so what gets rendered becomes a function of the data rather than of +// when it was rendered. +// +// The number is epoch **milliseconds**, the same units `Date.now()` returns. +// Worth stating because a file's `lastModified` and `resourceCreatedAt` are +// stamped in epoch *seconds*, and `toDate` in +// `base/file-formats/file-presentation` exists to promote those — so seconds +// are the value nearest to hand for someone reaching for a pin. A +// seconds-valued pin lands in 1970, puts every real timestamp in its future, +// and turns every age into an absolute date. It throws nothing and produces no +// NaN; a snapshot diff reads it as a formatting change. +// +// `now()` deliberately does not promote seconds the way `toDate` does. A file +// mtime below the year-2001 millisecond floor is unambiguous, but a pin is +// chosen by its caller, and guessing would hide the mistake rather than +// surface it. +// +// That matters wherever output is an age — "3d ago", a countdown, "Last saved +// 6 days ago", a `Today` / `Yesterday` day header. Each changes on a schedule +// nobody chose, so a visual comparison of one differs between two runs over +// identical data, and the usual way to quiet that is to stop comparing the +// element, which trades the coverage away. +// +// Reading through here is what makes a renderer pinnable; one that calls the +// clock directly is not, whatever this comment says. The rule that keeps them +// converted lives with the lint config, because a list of callers here would +// be wrong the moment one is added. +// +// It lives in runtime-common rather than in either consumer because both sides +// of the card loader need it: card code reaches it through +// `base/helpers/clock`, and the host app imports it directly. A global is what +// crosses that boundary — the loader gives card code its own module instances, +// so a shared module-level variable would not be shared at all. +// `__boxelRenderContext` and its neighbours already work this way. +export function now(): number { + let pinned = (globalThis as { __boxelNow?: unknown }).__boxelNow; + return typeof pinned === 'number' && Number.isFinite(pinned) + ? pinned + : Date.now(); +} + +// The same instant as a `Date`, for callers that would otherwise write +// `new Date()`. +export function nowDate(): Date { + return new Date(now()); +} diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index abedc36d5ca..c8427a5e767 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -21,6 +21,7 @@ import { rri, type RealmResourceIdentifier } from './realm-identifiers.ts'; import type { RealmEventContent } from '@cardstack/base/matrix-event'; import type { FileDef } from '@cardstack/base/file-api'; +export { now, nowDate } from './clock.ts'; export interface LooseSingleResourceDocument { data: LooseLinkableResource; included?: LooseLinkableResource[];