* fix(app): validate persisted client state Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate. * fix(app): preserve validated legacy state * fix(app): preserve validated preferences Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider. * fix(app): migrate legacy agent preferences Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields. * fix(app): preserve drafts with legacy reviews Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
458 lines
14 KiB
TypeScript
458 lines
14 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { StateStorage } from "zustand/middleware";
|
|
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
|
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
|
import {
|
|
buildReviewAttachmentSnapshot,
|
|
buildReviewDraftKey,
|
|
buildReviewDraftScopeKey,
|
|
} from "./store";
|
|
import {
|
|
addCommentToState,
|
|
clearReviewInState,
|
|
deleteCommentFromState,
|
|
type DiffModeOverride,
|
|
expireStaleDiffModeOverridesInState,
|
|
normalizePersistedState,
|
|
resolveDiffMode,
|
|
type ReviewDraftComment,
|
|
type ReviewDraftStoreState,
|
|
serializeReviewDraftState,
|
|
SerializedReviewDraftStateSchema,
|
|
setDiffModeOverrideInState,
|
|
updateCommentInState,
|
|
} from "./state";
|
|
|
|
function emptyState(): ReviewDraftStoreState {
|
|
return { drafts: {}, diffModeOverrides: {} };
|
|
}
|
|
|
|
function makeOverride(
|
|
input: Pick<DiffModeOverride, "mode" | "isDirtyAtSelection">,
|
|
): DiffModeOverride {
|
|
return { serverId: "server-1", cwd: "/repo", ...input };
|
|
}
|
|
|
|
function makeComment(overrides: Partial<ReviewDraftComment> = {}): ReviewDraftComment {
|
|
return {
|
|
id: "comment-1",
|
|
filePath: "src/example.ts",
|
|
side: "new",
|
|
lineNumber: 41,
|
|
body: "Please simplify this.",
|
|
createdAt: "2026-04-21T00:00:00.000Z",
|
|
updatedAt: "2026-04-21T00:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeFile(): ParsedDiffFile {
|
|
return {
|
|
path: "src/example.ts",
|
|
isNew: false,
|
|
isDeleted: false,
|
|
additions: 1,
|
|
deletions: 1,
|
|
status: "ok",
|
|
hunks: [
|
|
{
|
|
oldStart: 40,
|
|
oldCount: 4,
|
|
newStart: 40,
|
|
newCount: 4,
|
|
lines: [
|
|
{ type: "header", content: "@@ -40,4 +40,4 @@" },
|
|
{ type: "context", content: "const before = true;" },
|
|
{ type: "remove", content: "const value = oldValue;" },
|
|
{ type: "add", content: "const value = newValue;" },
|
|
{ type: "context", content: "return value;" },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function createMemoryStorage(): StateStorage & { values: Map<string, string> } {
|
|
const values = new Map<string, string>();
|
|
return {
|
|
values,
|
|
getItem: async (key) => values.get(key) ?? null,
|
|
setItem: async (key, value) => {
|
|
values.set(key, value);
|
|
},
|
|
removeItem: async (key) => {
|
|
values.delete(key);
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("buildReviewDraftKey", () => {
|
|
it("scopes by server, workspace-or-cwd, diff mode, base ref, and whitespace mode", () => {
|
|
const base = buildReviewDraftKey({
|
|
serverId: " local ",
|
|
workspaceId: " workspace-1 ",
|
|
cwd: "/repo",
|
|
mode: "base",
|
|
baseRef: " main ",
|
|
ignoreWhitespace: false,
|
|
});
|
|
|
|
expect(base).toBe(
|
|
"review:server=local:workspace=workspace-1:mode=base:base=main:ignoreWhitespace=false",
|
|
);
|
|
expect(
|
|
buildReviewDraftKey({
|
|
serverId: "local",
|
|
workspaceId: "workspace-1",
|
|
cwd: "/repo",
|
|
mode: "base",
|
|
baseRef: "main",
|
|
ignoreWhitespace: true,
|
|
}),
|
|
).not.toBe(base);
|
|
expect(
|
|
buildReviewDraftKey({
|
|
serverId: "local",
|
|
workspaceId: null,
|
|
cwd: "/repo/",
|
|
mode: "base",
|
|
baseRef: "main",
|
|
ignoreWhitespace: false,
|
|
}),
|
|
).toBe("review:server=local:cwd=%2Frepo:mode=base:base=main:ignoreWhitespace=false");
|
|
});
|
|
|
|
it("builds a mode-free scope key for diff mode override sharing", () => {
|
|
const scope = buildReviewDraftScopeKey({
|
|
serverId: "local",
|
|
workspaceId: "workspace-1",
|
|
cwd: "/repo",
|
|
baseRef: "main",
|
|
ignoreWhitespace: false,
|
|
});
|
|
|
|
expect(scope).toBe(
|
|
"review:server=local:workspace=workspace-1:base=main:ignoreWhitespace=false",
|
|
);
|
|
expect(scope).not.toContain("mode=");
|
|
});
|
|
});
|
|
|
|
describe("normalizePersistedState", () => {
|
|
it("keeps v1 review comments for migration while dropping the legacy mode field", async () => {
|
|
const backing = createMemoryStorage();
|
|
const legacyState = {
|
|
drafts: { "review:key": [makeComment()] },
|
|
activeModesByScope: { "review:scope": "base" },
|
|
};
|
|
backing.values.set(
|
|
"@paseo:review-draft-store",
|
|
JSON.stringify({ state: legacyState, version: 1 }),
|
|
);
|
|
const storage = createValidatedPersistStorage(backing, SerializedReviewDraftStateSchema);
|
|
|
|
const stored = await storage.getItem("@paseo:review-draft-store");
|
|
const normalized = normalizePersistedState(stored?.state);
|
|
|
|
expect(normalized.drafts["review:key"]).toEqual([makeComment()]);
|
|
expect(normalized.diffModeOverrides).toEqual({});
|
|
expect(backing.values.has("@paseo:review-draft-store")).toBe(true);
|
|
});
|
|
|
|
it("rejects the complete payload when any draft comment or field is invalid", () => {
|
|
const normalized = normalizePersistedState({
|
|
drafts: {
|
|
"review:key": [
|
|
{
|
|
id: "comment-1",
|
|
filePath: "src/example.ts",
|
|
side: "new",
|
|
lineNumber: 41,
|
|
body: "Keep me.",
|
|
createdAt: "2026-04-21T00:00:00.000Z",
|
|
updatedAt: "2026-04-21T00:00:00.000Z",
|
|
},
|
|
{ id: "bad", filePath: "src/example.ts" },
|
|
],
|
|
},
|
|
// Old persisted field — must be tolerated and ignored, not migrated.
|
|
activeModesByScope: {
|
|
"review:scope:base": "base",
|
|
"review:scope:dirty": "uncommitted",
|
|
},
|
|
});
|
|
|
|
expect(normalized.diffModeOverrides).toEqual({});
|
|
expect(normalized.drafts).toEqual({});
|
|
});
|
|
|
|
it("returns empty state for null, non-object, or malformed inputs", () => {
|
|
expect(normalizePersistedState(null)).toEqual({ drafts: {}, diffModeOverrides: {} });
|
|
expect(normalizePersistedState("nope")).toEqual({ drafts: {}, diffModeOverrides: {} });
|
|
expect(normalizePersistedState({ drafts: [] })).toEqual({
|
|
drafts: {},
|
|
diffModeOverrides: {},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("serializeReviewDraftState", () => {
|
|
it("serialized output does not contain activeModesByScope or diffModeOverrides", () => {
|
|
const state = setDiffModeOverrideInState(
|
|
addCommentToState(emptyState(), { key: "review:key", comment: makeComment() }),
|
|
{
|
|
scopeKey: "review:scope",
|
|
override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: true }),
|
|
},
|
|
);
|
|
|
|
const serialized = serializeReviewDraftState(state);
|
|
|
|
expect(Object.keys(serialized)).toEqual(["drafts"]);
|
|
expect("activeModesByScope" in serialized).toBe(false);
|
|
expect("diffModeOverrides" in serialized).toBe(false);
|
|
expect(serialized.drafts["review:key"]).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("diff mode override", () => {
|
|
it("resolves to auto mode from the dirty state when no override exists", () => {
|
|
expect(resolveDiffMode({ override: undefined, hasUncommittedChanges: true })).toBe(
|
|
"uncommitted",
|
|
);
|
|
expect(resolveDiffMode({ override: undefined, hasUncommittedChanges: false })).toBe("base");
|
|
});
|
|
|
|
it("honors the override while isDirty matches the value at selection, across remounts", () => {
|
|
const state = setDiffModeOverrideInState(emptyState(), {
|
|
scopeKey: "review:scope",
|
|
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
|
|
});
|
|
|
|
// Resolution is a plain store read, so it gives the same answer on every (re)mount.
|
|
const override = state.diffModeOverrides["review:scope"];
|
|
expect(resolveDiffMode({ override, hasUncommittedChanges: true })).toBe("base");
|
|
});
|
|
|
|
it("masks a stale override before its expiry lands", () => {
|
|
const state = setDiffModeOverrideInState(emptyState(), {
|
|
scopeKey: "review:scope",
|
|
override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: true }),
|
|
});
|
|
|
|
const override = state.diffModeOverrides["review:scope"];
|
|
expect(resolveDiffMode({ override, hasUncommittedChanges: false })).toBe("base");
|
|
});
|
|
|
|
it("expires overrides for the checkout whose dirty state flipped, keeping the rest", () => {
|
|
let state = setDiffModeOverrideInState(emptyState(), {
|
|
scopeKey: "review:scope-a",
|
|
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
|
|
});
|
|
state = setDiffModeOverrideInState(state, {
|
|
scopeKey: "review:scope-b",
|
|
override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: false }),
|
|
});
|
|
state = setDiffModeOverrideInState(state, {
|
|
scopeKey: "review:scope-other",
|
|
override: {
|
|
serverId: "server-2",
|
|
cwd: "/other",
|
|
mode: "base",
|
|
isDirtyAtSelection: true,
|
|
},
|
|
});
|
|
|
|
const next = expireStaleDiffModeOverridesInState(state, {
|
|
serverId: "server-1",
|
|
cwd: "/repo",
|
|
isDirty: false,
|
|
});
|
|
|
|
expect(next.diffModeOverrides["review:scope-a"]).toBeUndefined();
|
|
expect(next.diffModeOverrides["review:scope-b"]).toBeDefined();
|
|
expect(next.diffModeOverrides["review:scope-other"]).toBeDefined();
|
|
});
|
|
|
|
it("does not resurrect an expired override when the dirty state flips back", () => {
|
|
let state = setDiffModeOverrideInState(emptyState(), {
|
|
scopeKey: "review:scope",
|
|
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
|
|
});
|
|
|
|
// The checkout goes clean (e.g. agent commits): the override expires at the boundary.
|
|
state = expireStaleDiffModeOverridesInState(state, {
|
|
serverId: "server-1",
|
|
cwd: "/repo",
|
|
isDirty: false,
|
|
});
|
|
// The checkout goes dirty again: nothing to expire, and nothing comes back.
|
|
state = expireStaleDiffModeOverridesInState(state, {
|
|
serverId: "server-1",
|
|
cwd: "/repo",
|
|
isDirty: true,
|
|
});
|
|
|
|
expect(state.diffModeOverrides["review:scope"]).toBeUndefined();
|
|
expect(
|
|
resolveDiffMode({
|
|
override: state.diffModeOverrides["review:scope"],
|
|
hasUncommittedChanges: true,
|
|
}),
|
|
).toBe("uncommitted");
|
|
});
|
|
|
|
it("keeps state identity when no override is stale", () => {
|
|
const state = setDiffModeOverrideInState(emptyState(), {
|
|
scopeKey: "review:scope",
|
|
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
|
|
});
|
|
|
|
expect(
|
|
expireStaleDiffModeOverridesInState(state, {
|
|
serverId: "server-1",
|
|
cwd: "/repo",
|
|
isDirty: true,
|
|
}),
|
|
).toBe(state);
|
|
expect(
|
|
expireStaleDiffModeOverridesInState(emptyState(), {
|
|
serverId: "server-1",
|
|
cwd: "/repo",
|
|
isDirty: false,
|
|
}),
|
|
).toEqual(emptyState());
|
|
});
|
|
});
|
|
|
|
describe("review draft reducers", () => {
|
|
it("adds, updates, and deletes draft comments by key", () => {
|
|
let state = emptyState();
|
|
const comment = makeComment();
|
|
|
|
state = addCommentToState(state, { key: "review:key", comment });
|
|
expect(state.drafts["review:key"]).toEqual([comment]);
|
|
|
|
state = updateCommentInState(state, {
|
|
key: "review:key",
|
|
id: comment.id,
|
|
updates: { body: "Please simplify this condition." },
|
|
updatedAt: "2026-04-21T00:01:00.000Z",
|
|
});
|
|
expect(state.drafts["review:key"]?.[0]).toEqual({
|
|
...comment,
|
|
body: "Please simplify this condition.",
|
|
updatedAt: "2026-04-21T00:01:00.000Z",
|
|
});
|
|
|
|
state = deleteCommentFromState(state, { key: "review:key", id: comment.id });
|
|
expect(state.drafts["review:key"]).toEqual([]);
|
|
});
|
|
|
|
it("keeps state identity on no-op updates, deletes, and clears", () => {
|
|
const state = addCommentToState(emptyState(), {
|
|
key: "review:key",
|
|
comment: makeComment(),
|
|
});
|
|
|
|
expect(
|
|
updateCommentInState(state, {
|
|
key: "review:key",
|
|
id: "missing",
|
|
updates: { body: "x" },
|
|
updatedAt: "2026-04-21T00:01:00.000Z",
|
|
}),
|
|
).toBe(state);
|
|
expect(deleteCommentFromState(state, { key: "review:key", id: "missing" })).toBe(state);
|
|
expect(clearReviewInState(state, { key: "other-key" })).toBe(state);
|
|
});
|
|
});
|
|
|
|
describe("buildReviewAttachmentSnapshot", () => {
|
|
it("builds a bounded workspace review attachment and skips missing targets", () => {
|
|
const snapshot = buildReviewAttachmentSnapshot({
|
|
reviewDraftKey: "review:key",
|
|
cwd: "/repo",
|
|
mode: "base",
|
|
baseRef: "main",
|
|
comments: [
|
|
{
|
|
id: "comment-1",
|
|
filePath: "src/example.ts",
|
|
side: "new",
|
|
lineNumber: 41,
|
|
body: "Please simplify this.",
|
|
createdAt: "2026-04-21T00:00:00.000Z",
|
|
updatedAt: "2026-04-21T00:00:00.000Z",
|
|
},
|
|
{
|
|
id: "comment-2",
|
|
filePath: "src/missing.ts",
|
|
side: "new",
|
|
lineNumber: 99,
|
|
body: "This target is stale.",
|
|
createdAt: "2026-04-21T00:00:00.000Z",
|
|
updatedAt: "2026-04-21T00:00:00.000Z",
|
|
},
|
|
],
|
|
diffFiles: [makeFile()],
|
|
});
|
|
|
|
expect(snapshot).toEqual({
|
|
kind: "review",
|
|
reviewDraftKey: "review:key",
|
|
commentCount: 1,
|
|
attachment: {
|
|
type: "review",
|
|
mimeType: "application/paseo-review",
|
|
cwd: "/repo",
|
|
mode: "base",
|
|
baseRef: "main",
|
|
comments: [
|
|
{
|
|
filePath: "src/example.ts",
|
|
side: "new",
|
|
lineNumber: 41,
|
|
body: "Please simplify this.",
|
|
context: {
|
|
hunkHeader: "@@ -40,4 +40,4 @@",
|
|
targetLine: {
|
|
oldLineNumber: null,
|
|
newLineNumber: 41,
|
|
type: "add",
|
|
content: "const value = newValue;",
|
|
},
|
|
lines: [
|
|
{
|
|
oldLineNumber: 40,
|
|
newLineNumber: 40,
|
|
type: "context",
|
|
content: "const before = true;",
|
|
},
|
|
{
|
|
oldLineNumber: 41,
|
|
newLineNumber: null,
|
|
type: "remove",
|
|
content: "const value = oldValue;",
|
|
},
|
|
{
|
|
oldLineNumber: null,
|
|
newLineNumber: 41,
|
|
type: "add",
|
|
content: "const value = newValue;",
|
|
},
|
|
{
|
|
oldLineNumber: 42,
|
|
newLineNumber: 42,
|
|
type: "context",
|
|
content: "return value;",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
});
|