Skip to content
Open
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
51 changes: 5 additions & 46 deletions packages/base/helpers/clock.ts
Original file line number Diff line number Diff line change
@@ -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';
7 changes: 6 additions & 1 deletion packages/host/app/resources/last-modified-date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
}
Expand Down
7 changes: 5 additions & 2 deletions packages/host/tests/helpers/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '.';

Expand Down Expand Up @@ -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<void>();
// Per adapter, so each realm's files stay inside the just-now window.
#nextMtime = createFixtureMtimeSequence();
#potentialModulesAndInstances: { content: any; url: URL }[] = [];
#mockMatrixUtils: MockUtils;

Expand All @@ -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('/');
Expand Down Expand Up @@ -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]) {
Expand Down
61 changes: 61 additions & 0 deletions packages/host/tests/helpers/test-clock.ts
Original file line number Diff line number Diff line change
@@ -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);
}
6 changes: 5 additions & 1 deletion packages/host/tests/integration/date-time-fields-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions packages/host/tests/test-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
backspace marked this conversation as resolved.

// 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.
Expand Down
8 changes: 7 additions & 1 deletion packages/host/tests/unit/file-view-model-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions packages/runtime-common/clock.ts
Original file line number Diff line number Diff line change
@@ -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());
}
1 change: 1 addition & 0 deletions packages/runtime-common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends LinkableResource> {
data: LooseLinkableResource<T>;
included?: LooseLinkableResource<LinkableResource>[];
Expand Down
Loading