diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 83ff2db5748..b793f9f4586 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -1,10 +1,15 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { assert } from "@effect/vitest"; +import * as Schema from "effect/Schema"; +import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; +import { beforeEach, describe, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => { const values = new Map(); + const files = new Map(); return { clear: () => values.clear(), + clearFiles: () => files.clear(), + files, getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)), setItemAsync: vi.fn((key: string, value: string) => { values.set(key, value); @@ -13,6 +18,57 @@ const mocks = vi.hoisted(() => { }; }); +vi.mock("expo-file-system", () => ({ + Directory: class { + readonly uri: string; + + constructor(parent: string, name: string) { + this.uri = `${parent}/${name}`; + } + + create(): void { + // Directory creation is idempotent for these storage tests. + } + }, + File: class { + readonly uri: string; + + constructor(directory: { readonly uri: string }, name: string) { + this.uri = `${directory.uri}/${name}`; + } + + get exists(): boolean { + return mocks.files.has(this.uri) && mocks.files.get(this.uri)?.deleted === false; + } + + create(): void { + mocks.files.set(this.uri, { text: "", deleted: false }); + } + + delete(): void { + const entry = mocks.files.get(this.uri); + if (entry) { + entry.deleted = true; + } + } + + text(): string { + const entry = mocks.files.get(this.uri); + if (!entry || entry.deleted) { + throw new Error("missing file"); + } + return entry.text; + } + + write(text: string): void { + mocks.files.set(this.uri, { text, deleted: false }); + } + }, + Paths: { + document: "document", + }, +})); + vi.mock("expo-secure-store", () => ({ getItemAsync: mocks.getItemAsync, setItemAsync: mocks.setItemAsync, @@ -30,9 +86,18 @@ vi.mock("./runtime", () => ({ }, })); -import { loadSavedConnections, saveConnection } from "./storage"; +import { + loadCachedShellSnapshot, + loadSavedConnections, + saveCachedShellSnapshot, + saveConnection, + type CachedShellSnapshot, +} from "./storage"; import { toStableSavedRemoteConnection } from "./connection"; +const decodeUnknownJsonString = Schema.decodeUnknownSync(Schema.UnknownFromJsonString); +const encodeUnknownJsonString = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + const managedConnection = { environmentId: EnvironmentId.make("environment-1"), environmentLabel: "Desktop", @@ -46,9 +111,30 @@ const managedConnection = { relayManaged: true, } as const; +const cacheEnvironmentId = EnvironmentId.make("cache-environment-1"); +const otherCacheEnvironmentId = EnvironmentId.make("cache-environment-2"); + +const cachedSnapshot: OrchestrationShellSnapshot = { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +const cacheFileUri = (environmentId: EnvironmentId) => + `document/shell-snapshots/${encodeURIComponent(environmentId)}.json`; + +const writeCachedSnapshotFile = (environmentId: EnvironmentId, document: unknown) => { + mocks.files.set(cacheFileUri(environmentId), { + text: typeof document === "string" ? document : encodeUnknownJsonString(document), + deleted: false, + }); +}; + describe("mobile connection storage", () => { beforeEach(() => { mocks.clear(); + mocks.clearFiles(); vi.clearAllMocks(); }); @@ -56,8 +142,8 @@ describe("mobile connection storage", () => { await saveConnection(managedConnection); const savedValue = mocks.setItemAsync.mock.calls[0]?.[1]; - expect(savedValue).toBeDefined(); - expect(JSON.parse(savedValue ?? "")).toEqual({ + assert.notEqual(savedValue, undefined); + assert.deepStrictEqual(decodeUnknownJsonString(savedValue ?? ""), { connections: [toStableSavedRemoteConnection(managedConnection)], }); }); @@ -65,8 +151,46 @@ describe("mobile connection storage", () => { it("loads relay-managed connection metadata without a cached access token", async () => { await saveConnection(managedConnection); - await expect(loadSavedConnections()).resolves.toEqual([ + assert.deepStrictEqual(await loadSavedConnections(), [ toStableSavedRemoteConnection(managedConnection), ]); }); + + it("loads cached shell snapshots through the schema JSON codec", async () => { + await saveCachedShellSnapshot(cacheEnvironmentId, cachedSnapshot); + + const loaded = await loadCachedShellSnapshot(cacheEnvironmentId); + + assert.deepStrictEqual(loaded?.snapshot, cachedSnapshot); + assert.equal(loaded?.environmentId, cacheEnvironmentId); + }); + + it("ignores malformed cached shell snapshot JSON", async () => { + writeCachedSnapshotFile(cacheEnvironmentId, "{"); + + assert.equal(await loadCachedShellSnapshot(cacheEnvironmentId), null); + }); + + it("ignores cached shell snapshots with an unsupported schema version", async () => { + writeCachedSnapshotFile(cacheEnvironmentId, { + schemaVersion: 2, + environmentId: cacheEnvironmentId, + snapshotReceivedAt: "2026-01-01T00:00:00.000Z", + snapshot: cachedSnapshot, + }); + + assert.equal(await loadCachedShellSnapshot(cacheEnvironmentId), null); + }); + + it("ignores cached shell snapshots written for a different environment", async () => { + const document: CachedShellSnapshot = { + schemaVersion: 1, + environmentId: otherCacheEnvironmentId, + snapshotReceivedAt: "2026-01-01T00:00:00.000Z", + snapshot: cachedSnapshot, + }; + writeCachedSnapshotFile(cacheEnvironmentId, document); + + assert.equal(await loadCachedShellSnapshot(cacheEnvironmentId), null); + }); }); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index 2f9e4962c1a..a97c49158f6 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -35,7 +35,9 @@ const CachedShellSnapshotSchema = Schema.Struct({ snapshotReceivedAt: Schema.String, snapshot: OrchestrationShellSnapshot, }); -const decodeCachedShellSnapshot = Schema.decodeUnknownOption(CachedShellSnapshotSchema); +const CachedShellSnapshotJson = Schema.fromJsonString(CachedShellSnapshotSchema); +const decodeCachedShellSnapshotJson = Schema.decodeUnknownOption(CachedShellSnapshotJson); +const encodeCachedShellSnapshotJson = Schema.encodeOption(CachedShellSnapshotJson); async function readStorageItem(key: string): Promise { return await SecureStore.getItemAsync(key); @@ -80,8 +82,7 @@ export async function loadCachedShellSnapshot( return null; } - const parsed = JSON.parse(await file.text()) as unknown; - const decoded = decodeCachedShellSnapshot(parsed); + const decoded = decodeCachedShellSnapshotJson(await file.text()); if (Option.isNone(decoded) || decoded.value.environmentId !== environmentId) { return null; } @@ -106,11 +107,15 @@ export async function saveCachedShellSnapshot( snapshotReceivedAt: new Date().toISOString(), snapshot, }; + const encoded = encodeCachedShellSnapshotJson(document); + if (Option.isNone(encoded)) { + return; + } if (!file.exists) { file.create({ intermediates: true, overwrite: true }); } - file.write(JSON.stringify(document)); + file.write(encoded.value); } catch { // Cache persistence is best-effort and should never block live data. } diff --git a/packages/shared/src/DrainableWorker.test.ts b/packages/shared/src/DrainableWorker.test.ts index 8e4c654e2e4..b88eaca8eb7 100644 --- a/packages/shared/src/DrainableWorker.test.ts +++ b/packages/shared/src/DrainableWorker.test.ts @@ -1,5 +1,4 @@ -import { it } from "@effect/vitest"; -import { describe, expect } from "vite-plus/test"; +import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -45,12 +44,12 @@ describe("makeDrainableWorker", () => { yield* Deferred.succeed(releaseFirst, undefined); yield* Deferred.await(secondStarted); - expect(yield* Deferred.isDone(drained)).toBe(false); + assert.equal(yield* Deferred.isDone(drained), false); yield* Deferred.succeed(releaseSecond, undefined); yield* Deferred.await(drained); - expect(processed).toEqual(["first", "second"]); + assert.deepStrictEqual(processed, ["first", "second"]); }), ), ); diff --git a/packages/shared/src/KeyedCoalescingWorker.test.ts b/packages/shared/src/KeyedCoalescingWorker.test.ts index 8bfc1a340a6..0354fb30095 100644 --- a/packages/shared/src/KeyedCoalescingWorker.test.ts +++ b/packages/shared/src/KeyedCoalescingWorker.test.ts @@ -1,5 +1,4 @@ -import { it } from "@effect/vitest"; -import { describe, expect } from "vite-plus/test"; +import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -47,12 +46,12 @@ describe("makeKeyedCoalescingWorker", () => { yield* Deferred.succeed(releaseFirst, undefined); yield* Deferred.await(secondStarted); - expect(yield* Deferred.isDone(drained)).toBe(false); + assert.equal(yield* Deferred.isDone(drained), false); yield* Deferred.succeed(releaseSecond, undefined); yield* Deferred.await(drained); - expect(processed).toEqual(["terminal-1:first", "terminal-1:second"]); + assert.deepStrictEqual(processed, ["terminal-1:first", "terminal-1:second"]); }), ), ); @@ -90,7 +89,7 @@ describe("makeKeyedCoalescingWorker", () => { yield* Deferred.await(secondProcessed); yield* worker.drainKey("terminal-1"); - expect(processed).toEqual(["terminal-1:first", "terminal-1:second"]); + assert.deepStrictEqual(processed, ["terminal-1:first", "terminal-1:second"]); }), ), );