Skip to content
Closed
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
136 changes: 130 additions & 6 deletions apps/mobile/src/lib/storage.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
const files = new Map<string, { text: string; deleted: boolean }>();
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);
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -46,27 +111,86 @@ 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();
});

it("persists relay-managed connections without their ephemeral access token", async () => {
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)],
});
});

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);
});
});
13 changes: 9 additions & 4 deletions apps/mobile/src/lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
return await SecureStore.getItemAsync(key);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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.
}
Expand Down
7 changes: 3 additions & 4 deletions packages/shared/src/DrainableWorker.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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"]);
}),
),
);
Expand Down
9 changes: 4 additions & 5 deletions packages/shared/src/KeyedCoalescingWorker.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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"]);
}),
),
);
Expand Down Expand Up @@ -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"]);
}),
),
);
Expand Down
Loading