diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0dea2a7..a725714 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,6 +80,11 @@ jobs: - name: Install dependencies run: npm ci + # Not a test, but cheap and with nothing else to gate it: a stray `Date` passes every + # test that does not happen to compare two timestamps as strings. + - name: Check Temporal is the only date API + run: npm run check:temporal + # Split by OS rather than run both: `test:coverage` is `vitest run --coverage`, the # same fourteen files end to end, so ubuntu was running the whole suite twice. - name: Run tests diff --git a/CHANGELOG.md b/CHANGELOG.md index be17930..fbe3849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ popover, not for the person who wrote the commit. ## [Unreleased] +## [1.9.1] - 2026-10-01 + +### Fixed +- **An Enterprise-billed account no longer syncs on its own, without an assigned seat.** + The Enterprise tier used to turn on Cloud Sync by itself, so the account a subscription is + billed to - the owner - got full access before ever assigning themselves one of their own + seats. Cloud Sync now needs a Pro subscription or an actually assigned Enterprise seat, and + an owner gets one the same way a teammate does +- **An export's suggested file name carries the right date around midnight.** The name took + its date from UTC and its time from your own clock, so an export made in the hours between + your midnight and UTC's was named with the day before or the day after. Both now come from + your own calendar + ## [1.9.0] - 2026-09-27 ### Added diff --git a/TESTING.md b/TESTING.md index 9cfb84c..82ab41a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -199,7 +199,7 @@ mod tests { content: "test content".to_string(), attachments: vec![], files: vec![], - created_at: chrono::Utc::now().to_rfc3339(), + created_at: crate::time::now_iso(), context_id: "default".to_string(), completed: false, completed_at: None, @@ -363,6 +363,13 @@ jobs: expect(formattedDate).toContain('2025'); ``` + Fix "now" with `setClock` from `$lib/utils/time`, and restore it with `setClock(null)` + in `afterEach`. All app code reads the clock through that module, never `Date` - + `npm run check:temporal` fails on a `Date` anywhere, tests included. Build timestamps + with `fromEpochMs` or `toCanonical` so they have the same nine-digit form the app + writes. `vi.useFakeTimers()` still moves the clock as well, because the polyfill + reads the time through it. + 2. **File API Mocking**: jsdom doesn't fully support File APIs ```typescript // Mock arrayBuffer for File objects in tests diff --git a/package-lock.json b/package-lock.json index 16d17ba..29935a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stashpad", - "version": "1.8.7", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stashpad", - "version": "1.8.7", + "version": "1.9.0", "license": "AGPL-3.0", "dependencies": { "@tailwindcss/typography": "^0.5.19", @@ -29,7 +29,8 @@ "marked-highlight": "^2.2.3", "svelte-dnd-action": "^0.9.68", "svelte-i18n": "^4.0.1", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "temporal-polyfill": "^1.0.5" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", @@ -4552,6 +4553,28 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/temporal-polyfill": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-1.0.5.tgz", + "integrity": "sha512-+H/mmY74i6wXCMcjIhGuSnODcyZnc4eois2myhOyX0UqRAKNXQY5m9iB1en5jQ/n4SXtjLXo1ElBI5bmn8tZwQ==", + "license": "MIT", + "dependencies": { + "temporal-spec": "1.0.1", + "temporal-utils": "1.0.3" + } + }, + "node_modules/temporal-spec": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-1.0.1.tgz", + "integrity": "sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==", + "license": "Apache-2.0" + }, + "node_modules/temporal-utils": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/temporal-utils/-/temporal-utils-1.0.3.tgz", + "integrity": "sha512-9jbTSX3HvkOWvDpNpFzwX/d1mkCrgQgwLmVTOEiuocZGKHsrQJtttGZFmybJ2zYP5zCSZuU5ZyeS8+eOEPABiQ==", + "license": "MIT" + }, "node_modules/timers-ext": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz", diff --git a/package.json b/package.json index e4f220f..a7ecd9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stashpad", - "version": "1.9.0", + "version": "1.9.1", "description": "The staging area for your AI context.", "author": { "name": "Nico Wiedemann", @@ -25,6 +25,7 @@ "scripts": { "sync-version": "node scripts/sync-version.mjs", "check:changelog": "node scripts/check-changelog.mjs", + "check:temporal": "node scripts/check-temporal.mjs", "predev": "npm run sync-version", "dev": "tauri dev", "vite:dev": "vite", @@ -85,6 +86,7 @@ "marked-highlight": "^2.2.3", "svelte-dnd-action": "^0.9.68", "svelte-i18n": "^4.0.1", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "temporal-polyfill": "^1.0.5" } } diff --git a/screenshots/capture.mjs b/screenshots/capture.mjs index 89c8dcc..717f041 100644 --- a/screenshots/capture.mjs +++ b/screenshots/capture.mjs @@ -262,8 +262,8 @@ async function startDevServer() { child.stderr.on('data', (d) => process.stderr.write(d)); // Cold, Vite spends most of a minute pre-bundling before it answers. - const deadline = Date.now() + 180_000; - while (Date.now() < deadline) { + const deadline = performance.now() + 180_000; + while (performance.now() < deadline) { if (await demoPageIsUp()) return child; if (child.exitCode !== null) throw new Error(`the dev server exited (${child.exitCode})`); await new Promise((r) => setTimeout(r, 500)); diff --git a/screenshots/demo.ts b/screenshots/demo.ts index 7f5c5d2..ea66355 100644 --- a/screenshots/demo.ts +++ b/screenshots/demo.ts @@ -44,26 +44,16 @@ installMockBackend({ settings }); /** * Freeze the clock at the fixtures' instant. * - * Relative timestamps ("6m ago") are computed against `Date.now()`, so without this the - * same scene captured twice produces two different images and every release ships a - * screenshot diff that means nothing. Only the reading of "now" is frozen - timers still - * run, or the app would never finish starting. + * Relative timestamps ("6m ago") are computed against the current time, so without this + * the same scene captured twice produces two different images and every release ships a + * screenshot diff that means nothing. Every reading of "now" in the app goes through + * `$lib/utils/time`, so fixing its clock is enough. Timers still run, or the app would + * never finish starting. */ async function freezeClock(): Promise { const { NOW } = await import('./fixtures'); - const RealDate = Date; - const fixed = NOW.getTime(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const Frozen: any = function (this: unknown, ...args: unknown[]) { - return args.length === 0 - ? new RealDate(fixed) - : new (RealDate as unknown as new (...a: unknown[]) => Date)(...args); - }; - Frozen.prototype = RealDate.prototype; - Frozen.now = () => fixed; - Frozen.parse = RealDate.parse; - Frozen.UTC = RealDate.UTC; - globalThis.Date = Frozen; + const { setClock } = await import('../src/lib/utils/time'); + setClock(() => NOW); } await freezeClock(); diff --git a/screenshots/fixtures.ts b/screenshots/fixtures.ts index 7315c34..951dfc8 100644 --- a/screenshots/fixtures.ts +++ b/screenshots/fixtures.ts @@ -18,19 +18,20 @@ * fixture: these end up on the public website, and a screenshot is the easiest place * to leak a customer name or a home directory without noticing. * - * Timestamps are relative to a fixed instant (`NOW`) rather than to `Date.now()`, so a + * Timestamps are relative to a fixed instant (`NOW`) rather than to the real clock, so a * capture taken today and one taken next month produce the same images. The app renders * "2h ago" style labels from these, which would otherwise churn every release. */ import type { Attachment, Context, Settings, StashItem } from '../src/lib/types'; +import { Temporal, toCanonical } from '../src/lib/utils/time'; /** The instant the fixtures pretend it is. */ -export const NOW = new Date('2026-01-15T14:30:00.000Z'); +export const NOW = Temporal.Instant.from('2026-01-15T14:30:00.000Z'); /** An ISO timestamp `minutes` before `NOW`. */ function ago(minutes: number): string { - return new Date(NOW.getTime() - minutes * 60_000).toISOString(); + return toCanonical(NOW.subtract({ minutes })); } /** @@ -209,7 +210,7 @@ export const settings: Settings = { email: 'dev@example.com', subscriptionTier: 'pro', subscriptionStatus: 'active', - subscriptionPeriodEnd: new Date(NOW.getTime() + 21 * 86_400_000).toISOString(), + subscriptionPeriodEnd: toCanonical(NOW.add({ hours: 21 * 24 })), lastSyncAt: ago(2), }, }; diff --git a/screenshots/mock-backend.ts b/screenshots/mock-backend.ts index 2e3707d..a3fd298 100644 --- a/screenshots/mock-backend.ts +++ b/screenshots/mock-backend.ts @@ -29,6 +29,7 @@ import type { InvokeArgs } from '@tauri-apps/api/core'; import { mockIPC, mockWindows } from '@tauri-apps/api/mocks'; import type { Attachment, Context, Settings, StashItem } from '../src/lib/types'; import * as fixtures from './fixtures'; +import { toCanonical } from '../src/lib/utils/time'; /** Commands whose answer is "nothing happened, carry on". */ const NO_OP = new Set([ @@ -172,7 +173,7 @@ export function installMockBackend(overrides: Partial = {}): void { case 'sync_stashes_api': return { synced: [], - serverTime: fixtures.NOW.toISOString(), + serverTime: toCanonical(fixtures.NOW), rejected: [], partial: true, positions: [], @@ -180,7 +181,7 @@ export function installMockBackend(overrides: Partial = {}): void { case 'sync_contexts_api': return { synced: [], - serverTime: fixtures.NOW.toISOString(), + serverTime: toCanonical(fixtures.NOW), rejected: [], }; case 'upload_attachment_to_cloud': @@ -263,7 +264,7 @@ export function installMockBackend(overrides: Partial = {}): void { fileName, fileSize: 2_048, mimeType: 'text/plain', - createdAt: fixtures.NOW.toISOString(), + createdAt: toCanonical(fixtures.NOW), }; } } diff --git a/scripts/check-temporal.mjs b/scripts/check-temporal.mjs new file mode 100644 index 0000000..6f58a3e --- /dev/null +++ b/scripts/check-temporal.mjs @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Copyright (C) 2026 Nico Wiedemann +// +// This file is part of Stashpad. +// Stashpad is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License, version 3, +// as published by the Free Software Foundation. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. + +// Fails when anything in the tracked source reaches for an old date API instead of +// Temporal. +// +// Every date and time in the frontend goes through `src/lib/utils/time.ts`, which takes +// Temporal from the polyfill as a value, and every one in the backend through +// `src-tauri/src/time.rs`, on `temporal_rs`. Three things break that, and none of them +// shows up as an error: +// +// * A `Date` slips back in. It parses zoneless strings as local time in some engines and +// rejects them in others, and its `toISOString()` writes a different width than the +// backend, so two timestamps that look comparable as strings no longer are. +// * Something imports the polyfill on its own, or uses a global `Temporal`. The app would +// then run native Temporal on Windows and the polyfill on macOS and Linux. +// * `chrono` or `SystemTime` comes back in the backend, with its own idea of the format. +// +// Run with `npm run check:temporal`. CI runs it with the frontend tests. +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +const TIME_MODULE = 'src/lib/utils/time.ts'; +const RUST_TIME_MODULE = 'src-tauri/src/time.rs'; + +const RULES = [ + { + what: '`Date` - use the helpers in src/lib/utils/time.ts', + files: /\.(ts|js|mjs|svelte)$/, + pattern: /\bnew Date\b|\bDate\.[A-Za-z]|\binstanceof Date\b|[:<|,(]\s*Date\b(?!\s*:)|\bglobalThis\.Date\b/, + }, + { + what: 'the Temporal polyfill imported outside src/lib/utils/time.ts', + files: /\.(ts|js|mjs|svelte)$/, + pattern: /["'](temporal-polyfill|@js-temporal\/polyfill)(\/[^"']*)?["']/, + allow: TIME_MODULE, + }, + { + what: 'a global Temporal - import it from src/lib/utils/time.ts', + files: /\.(ts|js|mjs|svelte)$/, + pattern: /\bglobalThis\.Temporal\b/, + }, + { + what: '`chrono` - use src-tauri/src/time.rs', + files: /\.rs$|Cargo\.toml$/, + pattern: /\bchrono\b/, + }, + { + what: '`SystemTime` - use src-tauri/src/time.rs', + files: /\.rs$/, + pattern: /\bSystemTime\b|\bUNIX_EPOCH\b/, + allow: RUST_TIME_MODULE, + }, +]; + +/** Comment lines, which may name what was replaced. */ +const COMMENT = /^\s*(\/\/|\*|\/\*|