mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-03 02:27:57 +09:00
## Summary This improves how agents discover, author, inspect, and verify Webstudio projects while reducing unnecessary MCP context usage. ## What changed - Adds an **Agents** section to Project Settings with ready-to-run setup commands and client configuration for Claude Code, Codex, Cursor, and VS Code. - Makes large list and schema responses compact and paginated by default, with **verbose** available when complete records or diagnostics are needed. - Applies consistent compact output to assets, instances, styles, design tokens, CLI schemas, and project-session metadata. - Returns actionable expression warnings for props, resources, and Collection bindings, including unknown variables and risky render-time mutation resources. - Improves Collection expression guidance so agents can bind array items with the correct scoped variables. - Reports rendered audit outcomes explicitly as complete, partial, confirmation-required, or failed. - Expands runtime output schemas so MCP clients can validate responses reliably. - Strengthens the packaged CLI smoke workflow with a realistic seven-page stdio MCP scenario. ## Architecture - Consolidates `project-build` from 65 file-oriented entrypoints to 11 architecture-owned surfaces. - Separates the core model, contracts, runtime authoring, transfer, state, project sessions, MCP, visual tooling, persistence codecs, and authenticated server services. - Isolates Node-only JSX evaluation from browser-safe transfer utilities and authenticated server modules. - Keeps persistence codecs independent from authenticated database services, preventing unrelated module initialization. - Defines shared instance-path types once and reuses them across runtime modules. - Preserves Immer initialization through explicit package side-effect metadata. - Adds package tests that prevent a return to source-file entrypoints or generic exposure of Node-only surfaces. ## Validation - Full repository checks, tests, typechecks, generated-file checks, and fixtures - All 163 Builder test files and 2,141 tests - All 71 project-build test files and 1,794 tests - Lint and package-boundary validation - Full packed CLI release smoke test, including install, preview, agent setup, seven-page stdio MCP workflow, rendered audit, and structured commands
935 lines
23 KiB
TypeScript
935 lines
23 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*/
|
|
import {
|
|
describe,
|
|
test,
|
|
expect,
|
|
vi,
|
|
beforeEach,
|
|
afterEach,
|
|
type MockInstance,
|
|
} from "vitest";
|
|
import type { Backoff } from "@webstudio-is/sync-client";
|
|
|
|
// Module mocks
|
|
|
|
const {
|
|
mockBuildPatch,
|
|
mockCreateNativeClient,
|
|
mockCreateBackoff,
|
|
mockLoadBuilderData,
|
|
} = vi.hoisted(() => ({
|
|
mockBuildPatch: vi.fn(),
|
|
mockCreateNativeClient: vi.fn(),
|
|
mockCreateBackoff: vi.fn(),
|
|
mockLoadBuilderData: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/shared/trpc/trpc-client", () => ({
|
|
createNativeClient: mockCreateNativeClient,
|
|
nativeClient: { build: { patch: { mutate: mockBuildPatch } } },
|
|
}));
|
|
|
|
vi.mock("~/env/env.static", () => ({
|
|
publicStaticEnv: { VERSION: "test-version" },
|
|
}));
|
|
|
|
vi.mock("@webstudio-is/sync-client", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@webstudio-is/sync-client")>()),
|
|
createBackoff: mockCreateBackoff,
|
|
}));
|
|
|
|
vi.mock("~/shared/builder-data", () => ({
|
|
loadBuilderData: mockLoadBuilderData,
|
|
}));
|
|
|
|
// Imports (after mocks)
|
|
|
|
import type { BuilderPatchChange } from "@webstudio-is/project-build/contracts";
|
|
import {
|
|
$hasUnsavedSyncChanges,
|
|
$syncStatus,
|
|
type Transaction,
|
|
} from "@webstudio-is/sync-client";
|
|
import { toast } from "@webstudio-is/design-system";
|
|
import type { ServerSyncState } from "./sync-stores";
|
|
import {
|
|
$lastTransactionId,
|
|
commandQueue,
|
|
isSyncIdle,
|
|
onTransactionComplete,
|
|
onNextTransactionComplete,
|
|
enqueueProjectDetails,
|
|
stopPolling,
|
|
ServerSyncStorage,
|
|
__testing__,
|
|
} from "./project-queue";
|
|
|
|
const { retry, pollCommands, transactionCallbacks } = __testing__;
|
|
let mockToastError: MockInstance<typeof toast.error>;
|
|
|
|
// Constants (duplicated to avoid exporting them just for tests)
|
|
|
|
const NEW_ENTRIES_INTERVAL = 1000;
|
|
const INTERVAL_RECOVERY = 2000;
|
|
const MAX_RETRY_RECOVERY = 5;
|
|
const MAX_ALLOWED_API_ERRORS = 5;
|
|
|
|
// Helpers
|
|
|
|
const flush = () => vi.advanceTimersByTimeAsync(0);
|
|
|
|
const makeTx = (id: string): Transaction<BuilderPatchChange[]> => ({
|
|
id,
|
|
object: "server" as const,
|
|
payload: [{ namespace: "pages", patches: [] }],
|
|
});
|
|
|
|
const initialState: ServerSyncState = new Map();
|
|
|
|
const createMockBackoff = (overrides: Partial<Backoff> = {}): Backoff => {
|
|
let failures = 0;
|
|
return {
|
|
next:
|
|
overrides.next ??
|
|
vi.fn(() => {
|
|
failures += 1;
|
|
return 10_000;
|
|
}),
|
|
reset:
|
|
overrides.reset ??
|
|
vi.fn(() => {
|
|
failures = 0;
|
|
}),
|
|
attempts: overrides.attempts ?? vi.fn(() => failures),
|
|
};
|
|
};
|
|
|
|
// Tests
|
|
|
|
describe("project-queue", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
$syncStatus.set({ status: "idle" });
|
|
$lastTransactionId.set(undefined);
|
|
commandQueue.dequeueAll();
|
|
transactionCallbacks.clear();
|
|
stopPolling();
|
|
|
|
// Default mocks
|
|
mockCreateBackoff.mockReturnValue(createMockBackoff());
|
|
mockCreateNativeClient.mockReturnValue({
|
|
build: { patch: { mutate: mockBuildPatch } },
|
|
});
|
|
mockBuildPatch.mockResolvedValue({ status: "ok" });
|
|
mockToastError = vi.spyOn(toast, "error").mockImplementation(() => "");
|
|
mockLoadBuilderData.mockResolvedValue({});
|
|
});
|
|
|
|
afterEach(() => {
|
|
stopPolling();
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// unload protection
|
|
|
|
describe("$hasUnsavedSyncChanges", () => {
|
|
test("is false when sync is idle", () => {
|
|
$syncStatus.set({ status: "idle" });
|
|
|
|
expect($hasUnsavedSyncChanges.get()).toBe(false);
|
|
});
|
|
|
|
test("is true while changes are not saved yet", () => {
|
|
$syncStatus.set({ status: "syncing" });
|
|
|
|
expect($hasUnsavedSyncChanges.get()).toBe(true);
|
|
});
|
|
});
|
|
|
|
// isSyncIdle
|
|
|
|
describe("isSyncIdle", () => {
|
|
test("resolves immediately when status is idle", async () => {
|
|
$syncStatus.set({ status: "idle" });
|
|
const result = await isSyncIdle();
|
|
expect(result).toEqual({ status: "idle" });
|
|
});
|
|
|
|
test("rejects immediately when status is fatal", async () => {
|
|
$syncStatus.set({ status: "fatal", error: "broken" });
|
|
await expect(isSyncIdle()).rejects.toThrow(
|
|
"Synchronization is in fatal state"
|
|
);
|
|
});
|
|
|
|
test("waits and resolves when status transitions to idle", async () => {
|
|
$syncStatus.set({ status: "syncing" });
|
|
|
|
let resolved = false;
|
|
isSyncIdle().then(() => {
|
|
resolved = true;
|
|
});
|
|
await flush();
|
|
expect(resolved).toBe(false);
|
|
|
|
$syncStatus.set({ status: "idle" });
|
|
await flush();
|
|
expect(resolved).toBe(true);
|
|
});
|
|
|
|
test("waits and rejects when status transitions to fatal", async () => {
|
|
$syncStatus.set({ status: "syncing" });
|
|
|
|
let error: Error | undefined;
|
|
isSyncIdle().catch((err: Error) => {
|
|
error = err;
|
|
});
|
|
await flush();
|
|
expect(error).toBeUndefined();
|
|
|
|
$syncStatus.set({ status: "fatal", error: "crashed" });
|
|
await flush();
|
|
expect(error?.message).toMatch("Synchronization is in fatal state");
|
|
});
|
|
|
|
test("ignores intermediate non-idle/non-fatal statuses", async () => {
|
|
$syncStatus.set({ status: "syncing" });
|
|
|
|
let resolved = false;
|
|
isSyncIdle().then(() => {
|
|
resolved = true;
|
|
});
|
|
await flush();
|
|
|
|
$syncStatus.set({ status: "recovering" });
|
|
await flush();
|
|
expect(resolved).toBe(false);
|
|
|
|
$syncStatus.set({ status: "failed" });
|
|
await flush();
|
|
expect(resolved).toBe(false);
|
|
|
|
$syncStatus.set({ status: "idle" });
|
|
await flush();
|
|
expect(resolved).toBe(true);
|
|
});
|
|
});
|
|
|
|
// onTransactionComplete
|
|
|
|
describe("onTransactionComplete", () => {
|
|
test("cleans up callbacks after 60s timeout", async () => {
|
|
const cb = vi.fn();
|
|
onTransactionComplete("tx-1", cb);
|
|
|
|
expect(transactionCallbacks.has("tx-1")).toBe(true);
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
|
|
expect(transactionCallbacks.has("tx-1")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// onNextTransactionComplete
|
|
|
|
describe("onNextTransactionComplete", () => {
|
|
test("fires when $lastTransactionId changes and transaction succeeds", async () => {
|
|
const cb = vi.fn();
|
|
onNextTransactionComplete(cb);
|
|
|
|
$lastTransactionId.set("tx-next");
|
|
|
|
const callbacks = transactionCallbacks.get("tx-next");
|
|
expect(callbacks).toBeDefined();
|
|
callbacks?.[0](true);
|
|
|
|
expect(cb).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
test("does not fire when transaction fails", async () => {
|
|
const cb = vi.fn();
|
|
onNextTransactionComplete(cb);
|
|
|
|
$lastTransactionId.set("tx-fail");
|
|
|
|
const callbacks = transactionCallbacks.get("tx-fail");
|
|
callbacks?.[0](false);
|
|
|
|
expect(cb).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// retry generator
|
|
|
|
describe("retry generator (backoff integration)", () => {
|
|
test("uses 'recovering' status for the first MAX_RETRY_RECOVERY attempts", async () => {
|
|
let failureCount = 0;
|
|
mockCreateBackoff.mockReturnValue(
|
|
createMockBackoff({
|
|
next: vi.fn(() => {
|
|
failureCount += 1;
|
|
return 10_000;
|
|
}),
|
|
attempts: vi.fn(() => failureCount),
|
|
})
|
|
);
|
|
|
|
const gen = retry();
|
|
await gen.next();
|
|
|
|
for (let i = 0; i < MAX_RETRY_RECOVERY; i++) {
|
|
const p = gen.next();
|
|
await vi.advanceTimersByTimeAsync(INTERVAL_RECOVERY);
|
|
await p;
|
|
|
|
expect($syncStatus.get()).toEqual({ status: "recovering" });
|
|
}
|
|
});
|
|
|
|
test("transitions to 'failed' status after MAX_RETRY_RECOVERY attempts", async () => {
|
|
let failureCount = 0;
|
|
const mockNext = vi.fn(() => {
|
|
failureCount += 1;
|
|
return 15_000;
|
|
});
|
|
mockCreateBackoff.mockReturnValue(
|
|
createMockBackoff({
|
|
next: mockNext,
|
|
attempts: vi.fn(() => failureCount),
|
|
})
|
|
);
|
|
|
|
const gen = retry();
|
|
await gen.next();
|
|
|
|
for (let i = 0; i < MAX_RETRY_RECOVERY; i++) {
|
|
const p = gen.next();
|
|
await vi.advanceTimersByTimeAsync(INTERVAL_RECOVERY);
|
|
await p;
|
|
}
|
|
|
|
const failedPromise = gen.next();
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
await failedPromise;
|
|
|
|
expect($syncStatus.get()).toEqual({ status: "failed" });
|
|
});
|
|
|
|
test("uses backoff.next() delay and shows toast in failed state", async () => {
|
|
let failureCount = 0;
|
|
const mockNext = vi.fn(() => {
|
|
failureCount += 1;
|
|
return 25_000;
|
|
});
|
|
mockCreateBackoff.mockReturnValue(
|
|
createMockBackoff({
|
|
next: mockNext,
|
|
attempts: vi.fn(() => failureCount),
|
|
})
|
|
);
|
|
|
|
const gen = retry();
|
|
await gen.next();
|
|
|
|
for (let i = 0; i < MAX_RETRY_RECOVERY; i++) {
|
|
const p = gen.next();
|
|
await vi.advanceTimersByTimeAsync(INTERVAL_RECOVERY);
|
|
await p;
|
|
}
|
|
|
|
const failedPromise = gen.next();
|
|
await vi.advanceTimersByTimeAsync(25_000);
|
|
await failedPromise;
|
|
|
|
expect(mockToastError).toHaveBeenCalledWith(
|
|
expect.stringContaining("Retry in 25 seconds")
|
|
);
|
|
});
|
|
|
|
test("calls backoff.next() in recovery to track attempts", async () => {
|
|
let failureCount = 0;
|
|
const mockNext = vi.fn(() => {
|
|
failureCount += 1;
|
|
return 5_000;
|
|
});
|
|
mockCreateBackoff.mockReturnValue(
|
|
createMockBackoff({
|
|
next: mockNext,
|
|
attempts: vi.fn(() => failureCount),
|
|
})
|
|
);
|
|
|
|
const gen = retry();
|
|
await gen.next();
|
|
|
|
const p = gen.next();
|
|
await vi.advanceTimersByTimeAsync(INTERVAL_RECOVERY);
|
|
await p;
|
|
|
|
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// pollCommands generator
|
|
|
|
describe("pollCommands generator", () => {
|
|
test("yields commands from the queue", async () => {
|
|
commandQueue.enqueue({
|
|
type: "setDetails",
|
|
projectId: "p1",
|
|
version: 1,
|
|
buildId: "b1",
|
|
authToken: undefined,
|
|
});
|
|
|
|
const gen = pollCommands();
|
|
const result = await gen.next();
|
|
|
|
expect(result.value).toEqual(
|
|
expect.objectContaining({ type: "setDetails", projectId: "p1" })
|
|
);
|
|
});
|
|
|
|
test("sets status to syncing when commands are available", async () => {
|
|
commandQueue.enqueue({
|
|
type: "setDetails",
|
|
projectId: "p1",
|
|
version: 1,
|
|
buildId: "b1",
|
|
authToken: undefined,
|
|
});
|
|
|
|
const gen = pollCommands();
|
|
await gen.next();
|
|
|
|
expect($syncStatus.get()).toEqual({ status: "syncing" });
|
|
});
|
|
|
|
test("sets status to idle and waits when queue is empty", async () => {
|
|
const gen = pollCommands();
|
|
|
|
const p = gen.next();
|
|
await vi.advanceTimersByTimeAsync(NEW_ENTRIES_INTERVAL);
|
|
|
|
commandQueue.enqueue({
|
|
type: "setDetails",
|
|
projectId: "p1",
|
|
version: 1,
|
|
buildId: "b1",
|
|
authToken: undefined,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(NEW_ENTRIES_INTERVAL);
|
|
const result = await p;
|
|
|
|
expect(result.value).toEqual(
|
|
expect.objectContaining({ type: "setDetails" })
|
|
);
|
|
});
|
|
});
|
|
|
|
// enqueueProjectDetails
|
|
|
|
describe("enqueueProjectDetails", () => {
|
|
test("skips enqueue for 'view' permit", () => {
|
|
enqueueProjectDetails({
|
|
projectId: "p1",
|
|
buildId: "b1",
|
|
version: 1,
|
|
authPermit: "view",
|
|
authToken: undefined,
|
|
});
|
|
|
|
const commands = commandQueue.dequeueAll();
|
|
expect(commands).toHaveLength(0);
|
|
});
|
|
|
|
test("enqueues setDetails for 'edit' permit", () => {
|
|
const enqueueSpy = vi.spyOn(commandQueue, "enqueue");
|
|
|
|
enqueueProjectDetails({
|
|
projectId: "p1",
|
|
buildId: "b1",
|
|
version: 1,
|
|
authPermit: "edit",
|
|
authToken: "tok",
|
|
});
|
|
|
|
expect(enqueueSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: "setDetails",
|
|
projectId: "p1",
|
|
version: 1,
|
|
buildId: "b1",
|
|
authToken: "tok",
|
|
})
|
|
);
|
|
|
|
enqueueSpy.mockRestore();
|
|
});
|
|
|
|
test("enqueues setDetails for 'own' permit", () => {
|
|
const enqueueSpy = vi.spyOn(commandQueue, "enqueue");
|
|
|
|
enqueueProjectDetails({
|
|
projectId: "p1",
|
|
buildId: "b1",
|
|
version: 1,
|
|
authPermit: "own",
|
|
authToken: undefined,
|
|
});
|
|
|
|
expect(enqueueSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: "setDetails" })
|
|
);
|
|
|
|
enqueueSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ServerSyncStorage
|
|
|
|
describe("ServerSyncStorage", () => {
|
|
test("subscribe seeds initial data without loading builder data again", () => {
|
|
const serverState: ServerSyncState = new Map([["pages", undefined]]);
|
|
const storage = new ServerSyncStorage("p1", serverState);
|
|
const setState = vi.fn();
|
|
mockLoadBuilderData.mockClear();
|
|
|
|
storage.subscribe(setState, new AbortController().signal);
|
|
|
|
expect(mockLoadBuilderData).not.toHaveBeenCalled();
|
|
expect(setState).toHaveBeenCalledWith(new Map([["server", serverState]]));
|
|
});
|
|
|
|
test("subscribe does nothing when aborted", () => {
|
|
const storage = new ServerSyncStorage("p1", initialState);
|
|
const controller = new AbortController();
|
|
const setState = vi.fn();
|
|
controller.abort();
|
|
|
|
storage.subscribe(setState, controller.signal);
|
|
|
|
expect(setState).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("sendTransaction enqueues only for 'server' object", () => {
|
|
const storage = new ServerSyncStorage("p1", initialState);
|
|
|
|
storage.sendTransaction({
|
|
id: "tx-1",
|
|
object: "server",
|
|
payload: [{ namespace: "pages", patches: [] }],
|
|
});
|
|
|
|
const commands = commandQueue.dequeueAll();
|
|
expect(commands).toHaveLength(1);
|
|
if (commands[0].type === "transactions") {
|
|
expect(commands[0].transactions[0].id).toBe("tx-1");
|
|
}
|
|
});
|
|
|
|
test("sendTransaction ignores non-server objects", () => {
|
|
const storage = new ServerSyncStorage("p1", initialState);
|
|
|
|
storage.sendTransaction({
|
|
id: "tx-1",
|
|
object: "client",
|
|
payload: [],
|
|
});
|
|
|
|
const commands = commandQueue.dequeueAll();
|
|
expect(commands).toHaveLength(0);
|
|
});
|
|
|
|
test("sendTransaction sets $lastTransactionId", () => {
|
|
const storage = new ServerSyncStorage("p1", initialState);
|
|
|
|
storage.sendTransaction({
|
|
id: "tx-42",
|
|
object: "server",
|
|
payload: [{ namespace: "pages", patches: [] }],
|
|
});
|
|
|
|
expect($lastTransactionId.get()).toBe("tx-42");
|
|
});
|
|
});
|
|
|
|
// pollQueue integration
|
|
|
|
describe("pollQueue integration", () => {
|
|
test("successful transaction increments version and fires callbacks", async () => {
|
|
mockBuildPatch.mockResolvedValue({ status: "ok" });
|
|
|
|
const txCallback = vi.fn();
|
|
onTransactionComplete("tx-success", txCallback);
|
|
|
|
enqueueProjectDetails({
|
|
projectId: "p1",
|
|
buildId: "b1",
|
|
version: 1,
|
|
authPermit: "edit",
|
|
authToken: "tok",
|
|
});
|
|
|
|
commandQueue.enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("tx-success")],
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(NEW_ENTRIES_INTERVAL * 3);
|
|
await flush();
|
|
|
|
expect(txCallback).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
test("sends auth token in RPC input and header", async () => {
|
|
mockBuildPatch.mockResolvedValue({ status: "ok" });
|
|
|
|
enqueueProjectDetails({
|
|
projectId: "p1",
|
|
buildId: "b1",
|
|
version: 1,
|
|
authPermit: "edit",
|
|
authToken: "tok",
|
|
});
|
|
commandQueue.enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("tx-auth")],
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(NEW_ENTRIES_INTERVAL * 3);
|
|
await flush();
|
|
|
|
expect(mockBuildPatch).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
authToken: "tok",
|
|
source: "browser",
|
|
})
|
|
);
|
|
expect(mockCreateNativeClient).toHaveBeenCalledWith({
|
|
"x-auth-token": "tok",
|
|
});
|
|
});
|
|
|
|
test("version_mismatched response sets fatal status", async () => {
|
|
vi.stubGlobal(
|
|
"confirm",
|
|
vi.fn(() => false)
|
|
);
|
|
|
|
mockBuildPatch.mockResolvedValue({
|
|
status: "version_mismatched",
|
|
errors: "Version mismatch",
|
|
});
|
|
|
|
enqueueProjectDetails({
|
|
projectId: "p1",
|
|
buildId: "b1",
|
|
version: 1,
|
|
authPermit: "edit",
|
|
authToken: "tok",
|
|
});
|
|
commandQueue.enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("tx-1")],
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(NEW_ENTRIES_INTERVAL * 3);
|
|
await flush();
|
|
|
|
expect($syncStatus.get().status).toBe("fatal");
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
test("authorization_error response sets fatal status", async () => {
|
|
vi.stubGlobal(
|
|
"confirm",
|
|
vi.fn(() => false)
|
|
);
|
|
|
|
mockBuildPatch.mockResolvedValue({
|
|
status: "authorization_error",
|
|
errors: "Not authorized",
|
|
});
|
|
|
|
enqueueProjectDetails({
|
|
projectId: "p1",
|
|
buildId: "b1",
|
|
version: 1,
|
|
authPermit: "edit",
|
|
authToken: "tok",
|
|
});
|
|
commandQueue.enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("tx-1")],
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(NEW_ENTRIES_INTERVAL * 3);
|
|
await flush();
|
|
|
|
expect($syncStatus.get().status).toBe("fatal");
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
test("partial response sets fatal status", async () => {
|
|
vi.stubGlobal(
|
|
"confirm",
|
|
vi.fn(() => false)
|
|
);
|
|
|
|
mockBuildPatch.mockResolvedValue({
|
|
status: "partial",
|
|
version: 1,
|
|
entries: [
|
|
{
|
|
seq: 0,
|
|
transactionId: "tx-1",
|
|
status: "failed",
|
|
errors: "Patch failed",
|
|
},
|
|
],
|
|
});
|
|
|
|
enqueueProjectDetails({
|
|
projectId: "p1",
|
|
buildId: "b1",
|
|
version: 1,
|
|
authPermit: "edit",
|
|
authToken: "tok",
|
|
});
|
|
commandQueue.enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("tx-1")],
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(NEW_ENTRIES_INTERVAL * 3);
|
|
await flush();
|
|
|
|
expect($syncStatus.get()).toEqual({
|
|
status: "fatal",
|
|
error: "Patch failed",
|
|
});
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
test("fatal after MAX_ALLOWED_API_ERRORS unknown API errors", async () => {
|
|
mockBuildPatch.mockResolvedValue({
|
|
status: "unknown_error",
|
|
errors: "Something bad",
|
|
});
|
|
|
|
enqueueProjectDetails({
|
|
projectId: "p1",
|
|
buildId: "b1",
|
|
version: 1,
|
|
authPermit: "edit",
|
|
authToken: "tok",
|
|
});
|
|
commandQueue.enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("tx-1")],
|
|
});
|
|
|
|
for (
|
|
let i = 0;
|
|
i < MAX_ALLOWED_API_ERRORS + MAX_RETRY_RECOVERY + 2;
|
|
i++
|
|
) {
|
|
await vi.advanceTimersByTimeAsync(
|
|
NEW_ENTRIES_INTERVAL + INTERVAL_RECOVERY + 30_000
|
|
);
|
|
await flush();
|
|
}
|
|
|
|
expect($syncStatus.get().status).toBe("fatal");
|
|
});
|
|
|
|
test("rpc errors retry without crashing", async () => {
|
|
let callCount = 0;
|
|
mockBuildPatch.mockImplementation(async () => {
|
|
callCount += 1;
|
|
if (callCount <= 2) {
|
|
throw new Error("Server Error");
|
|
}
|
|
return { status: "ok" };
|
|
});
|
|
|
|
enqueueProjectDetails({
|
|
projectId: "p1",
|
|
buildId: "b1",
|
|
version: 1,
|
|
authPermit: "edit",
|
|
authToken: "tok",
|
|
});
|
|
commandQueue.enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("tx-retry")],
|
|
});
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await vi.advanceTimersByTimeAsync(
|
|
NEW_ENTRIES_INTERVAL + INTERVAL_RECOVERY + 15_000
|
|
);
|
|
await flush();
|
|
}
|
|
|
|
expect(callCount).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
test("network error retries without crashing", async () => {
|
|
let callCount = 0;
|
|
mockBuildPatch.mockImplementation(async () => {
|
|
callCount += 1;
|
|
if (callCount <= 1) {
|
|
throw new Error("ERR_CONNECTION_REFUSED");
|
|
}
|
|
return { status: "ok" };
|
|
});
|
|
|
|
Object.defineProperty(navigator, "onLine", {
|
|
value: true,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
enqueueProjectDetails({
|
|
projectId: "p1",
|
|
buildId: "b1",
|
|
version: 1,
|
|
authPermit: "edit",
|
|
authToken: "tok",
|
|
});
|
|
commandQueue.enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("tx-net")],
|
|
});
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await vi.advanceTimersByTimeAsync(
|
|
NEW_ENTRIES_INTERVAL + INTERVAL_RECOVERY + 15_000
|
|
);
|
|
await flush();
|
|
}
|
|
|
|
expect(callCount).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
test("missing project details causes fatal status", async () => {
|
|
commandQueue.enqueue({
|
|
type: "transactions",
|
|
projectId: "p-unknown",
|
|
transactions: [makeTx("tx-orphan")],
|
|
});
|
|
|
|
enqueueProjectDetails({
|
|
projectId: "p-other",
|
|
buildId: "b-other",
|
|
version: 1,
|
|
authPermit: "edit",
|
|
authToken: undefined,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(NEW_ENTRIES_INTERVAL * 5);
|
|
await flush();
|
|
|
|
expect($syncStatus.get().status).toBe("fatal");
|
|
expect(mockToastError).toHaveBeenCalledWith(
|
|
expect.stringContaining("Project details not found"),
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
test("outdated version on setDetails sets fatal status", async () => {
|
|
vi.stubGlobal(
|
|
"confirm",
|
|
vi.fn(() => false)
|
|
);
|
|
|
|
mockBuildPatch.mockResolvedValue({ status: "ok" });
|
|
|
|
enqueueProjectDetails({
|
|
projectId: "p1",
|
|
buildId: "b1",
|
|
version: 5,
|
|
authPermit: "edit",
|
|
authToken: "tok",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(NEW_ENTRIES_INTERVAL);
|
|
await flush();
|
|
|
|
commandQueue.enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("tx-v")],
|
|
});
|
|
await vi.advanceTimersByTimeAsync(NEW_ENTRIES_INTERVAL * 2);
|
|
await flush();
|
|
|
|
commandQueue.enqueue({
|
|
type: "setDetails",
|
|
projectId: "p1",
|
|
version: 3,
|
|
buildId: "b1",
|
|
authToken: "tok",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(NEW_ENTRIES_INTERVAL * 2);
|
|
await flush();
|
|
|
|
expect($syncStatus.get().status).toBe("fatal");
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
});
|
|
|
|
// stopPolling
|
|
|
|
describe("stopPolling", () => {
|
|
test("can be called safely when not polling", () => {
|
|
expect(() => stopPolling()).not.toThrow();
|
|
});
|
|
|
|
test("stops the polling loop", async () => {
|
|
mockBuildPatch.mockResolvedValue({ status: "ok" });
|
|
|
|
enqueueProjectDetails({
|
|
projectId: "p1",
|
|
buildId: "b1",
|
|
version: 1,
|
|
authPermit: "edit",
|
|
authToken: "tok",
|
|
});
|
|
|
|
stopPolling();
|
|
mockBuildPatch.mockClear();
|
|
|
|
commandQueue.enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("tx-after-stop")],
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(NEW_ENTRIES_INTERVAL * 5);
|
|
await flush();
|
|
|
|
// No fetch calls should have happened after stopping
|
|
expect(mockBuildPatch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|