diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/apq.test.ts | 151 | ||||
| -rw-r--r-- | tests/format.test.ts | 28 | ||||
| -rw-r--r-- | tests/keyboard.test.ts | 111 | ||||
| -rw-r--r-- | tests/session-io.test.ts | 201 | ||||
| -rw-r--r-- | tests/timing.test.ts | 100 |
5 files changed, 591 insertions, 0 deletions
diff --git a/tests/apq.test.ts b/tests/apq.test.ts new file mode 100644 index 0000000..66607cd --- /dev/null +++ b/tests/apq.test.ts @@ -0,0 +1,151 @@ + + + + +/*** IMPORT ------------------------------------------- ***/ + +import { expect, test } from "vitest"; + +/*** UTILITY ------------------------------------------ ***/ + +import { createApqFetcher } from "../source/library/fetcher/apq.ts"; + +/*** HELPERS ------------------------------------------ ***/ + +type Call = { body: Record<string, unknown>; url: string }; + +function createStub(queue: Array<Record<string, unknown>>): { + calls: Call[]; + stub: typeof fetch; +} { + const calls: Call[] = []; + + const stub: typeof fetch = (input, init) => { + const body = JSON.parse((init?.body as string) ?? "null") as Record<string, unknown>; + calls.push({ body, url: String(input) }); + + const next = queue.shift(); + + if (next === undefined) + throw new Error("stub fetch exhausted"); + + return Promise.resolve(new Response(JSON.stringify(next), { status: 200 })); + }; + + return { calls, stub }; +} + +async function expectedHash(query: string): Promise<string> { + const buf = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(query)); + return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +function readPersistedHash(body: Record<string, unknown>): string { + const extensions = body.extensions as Record<string, unknown>; + const persistedQuery = extensions.persistedQuery as Record<string, unknown>; + return persistedQuery.sha256Hash as string; +} + +/*** TESTS -------------------------------------------- ***/ + +test("apq cache miss retries with the full query", async () => { + const { calls, stub } = createStub([ + { errors: [{ message: "PersistedQueryNotFound" }] }, + { data: { hello: "world" } } + ]); + + const fetcher = createApqFetcher({ + fetch: stub, + url: "https://example.test/graphql" + }); + + const result = await fetcher({ query: "{ hello }" }); + + expect(result).toEqual({ data: { hello: "world" } }); + expect(calls.length).toEqual(2); + expect(calls[0].body.query).toEqual(undefined); + expect(calls[1].body.query).toEqual("{ hello }"); +}); + +test("apq cache hit sends a single request without the query", async () => { + const { calls, stub } = createStub([{ data: { hello: "world" } }]); + + const fetcher = createApqFetcher({ + fetch: stub, + url: "https://example.test/graphql" + }); + + const result = await fetcher({ query: "{ hello }" }); + + expect(result).toEqual({ data: { hello: "world" } }); + expect(calls.length).toEqual(1); + expect(calls[0].body.query).toEqual(undefined); + expect(calls[0].body.extensions !== undefined).toBe(true); +}); + +test("apq hashes are stable across calls with the same query", async () => { + const { calls, stub } = createStub([ + { data: { hello: "world" } }, + { data: { hello: "world" } } + ]); + + const fetcher = createApqFetcher({ + fetch: stub, + url: "https://example.test/graphql" + }); + + await fetcher({ query: "{ hello }" }); + await fetcher({ query: "{ hello }" }); + + expect(calls.length).toEqual(2); + expect(readPersistedHash(calls[0].body)).toEqual(readPersistedHash(calls[1].body)); + expect(readPersistedHash(calls[0].body)).toEqual(await expectedHash("{ hello }")); +}); + +test("apq with disable sends the full query and the extension in one request", async () => { + const { calls, stub } = createStub([{ data: { hello: "world" } }]); + + const fetcher = createApqFetcher({ + disable: true, + fetch: stub, + url: "https://example.test/graphql" + }); + + const result = await fetcher({ query: "{ hello }" }); + + expect(result).toEqual({ data: { hello: "world" } }); + expect(calls.length).toEqual(1); + expect(calls[0].body.query).toEqual("{ hello }"); + expect(readPersistedHash(calls[0].body)).toEqual(await expectedHash("{ hello }")); +}); + +test("apq hash is lowercase hex 64 chars", async () => { + const { calls, stub } = createStub([{ data: { hello: "world" } }]); + + const fetcher = createApqFetcher({ + fetch: stub, + url: "https://example.test/graphql" + }); + + await fetcher({ query: "{ hello }" }); + + expect(readPersistedHash(calls[0].body)).toMatch(/^[0-9a-f]{64}$/); +}); + +test("apq accepts PERSISTED_QUERY_NOT_FOUND extension code", async () => { + const { calls, stub } = createStub([ + { errors: [{ extensions: { code: "PERSISTED_QUERY_NOT_FOUND" }, message: "nope" }] }, + { data: { hello: "world" } } + ]); + + const fetcher = createApqFetcher({ + fetch: stub, + url: "https://example.test/graphql" + }); + + const result = await fetcher({ query: "{ hello }" }); + + expect(result).toEqual({ data: { hello: "world" } }); + expect(calls.length).toEqual(2); + expect(calls[1].body.query).toEqual("{ hello }"); +}); diff --git a/tests/format.test.ts b/tests/format.test.ts new file mode 100644 index 0000000..ae5d40c --- /dev/null +++ b/tests/format.test.ts @@ -0,0 +1,28 @@ + + + + +/*** IMPORT ------------------------------------------- ***/ + +import { expect, test } from "vitest"; + +/*** UTILITY ------------------------------------------ ***/ + +import { format } from "../source/library/graphql/format.ts"; + +/*** TESTS -------------------------------------------- ***/ + +test("format pretty-prints a valid query", () => { + const input = "query Foo{viewer{id name}}"; + const expected = "query Foo {\n viewer {\n id\n name\n }\n}"; + expect(format(input)).toEqual(expected); +}); + +test("format returns the input unchanged on parse failure", () => { + const input = "query { ..."; + expect(format(input)).toEqual(input); +}); + +test("format returns empty string for empty input", () => { + expect(format("")).toEqual(""); +}); diff --git a/tests/keyboard.test.ts b/tests/keyboard.test.ts new file mode 100644 index 0000000..3a7f3cc --- /dev/null +++ b/tests/keyboard.test.ts @@ -0,0 +1,111 @@ + + + + +/*** IMPORT ------------------------------------------- ***/ + +import { expect, test } from "vitest"; + +/*** UTILITY ------------------------------------------ ***/ + +import { matchShortcut } from "../source/library/state/keyboard.ts"; + +type EventInit = { + altKey?: boolean; + ctrlKey?: boolean; + key: string; + metaKey?: boolean; + shiftKey?: boolean; +}; + +function makeEvent(init: EventInit): KeyboardEvent { + return { + altKey: init.altKey ?? false, + ctrlKey: init.ctrlKey ?? false, + key: init.key, + metaKey: init.metaKey ?? false, + shiftKey: init.shiftKey ?? false + } as KeyboardEvent; +} + +/*** TESTS -------------------------------------------- ***/ + +test("matchShortcut returns null for plain Enter", () => { + expect(matchShortcut(makeEvent({ key: "Enter" }))).toEqual(null); +}); + +test("matchShortcut returns null when no modifier is pressed", () => { + expect(matchShortcut(makeEvent({ key: "f", shiftKey: true }))).toEqual(null); +}); + +test("matchShortcut maps Cmd+Enter to run", () => { + expect( + matchShortcut(makeEvent({ key: "Enter", metaKey: true })) + ).toEqual({ type: "run" }); +}); + +test("matchShortcut maps Ctrl+Enter to run", () => { + expect( + matchShortcut(makeEvent({ ctrlKey: true, key: "Enter" })) + ).toEqual({ type: "run" }); +}); + +test("matchShortcut maps Cmd+Shift+Enter to newTab", () => { + expect( + matchShortcut(makeEvent({ key: "Enter", metaKey: true, shiftKey: true })) + ).toEqual({ type: "newTab" }); +}); + +test("matchShortcut maps Ctrl+Shift+Enter to newTab", () => { + expect( + matchShortcut(makeEvent({ ctrlKey: true, key: "Enter", shiftKey: true })) + ).toEqual({ type: "newTab" }); +}); + +test("matchShortcut maps Cmd+Shift+W to closeTab", () => { + expect( + matchShortcut(makeEvent({ key: "w", metaKey: true, shiftKey: true })) + ).toEqual({ type: "closeTab" }); +}); + +test("matchShortcut maps Ctrl+Shift+W to closeTab", () => { + expect( + matchShortcut(makeEvent({ ctrlKey: true, key: "W", shiftKey: true })) + ).toEqual({ type: "closeTab" }); +}); + +test("matchShortcut maps Cmd+Shift+F to format on mac", () => { + expect( + matchShortcut(makeEvent({ key: "f", metaKey: true, shiftKey: true })) + ).toEqual({ type: "format" }); +}); + +test("matchShortcut maps Ctrl+Shift+F to format on non-mac", () => { + expect( + matchShortcut(makeEvent({ ctrlKey: true, key: "F", shiftKey: true })) + ).toEqual({ type: "format" }); +}); + +test("matchShortcut maps Cmd+Alt+ArrowRight to nextTab", () => { + expect( + matchShortcut(makeEvent({ altKey: true, key: "ArrowRight", metaKey: true })) + ).toEqual({ type: "nextTab" }); +}); + +test("matchShortcut maps Ctrl+Alt+ArrowLeft to prevTab", () => { + expect( + matchShortcut(makeEvent({ altKey: true, ctrlKey: true, key: "ArrowLeft" })) + ).toEqual({ type: "prevTab" }); +}); + +test("matchShortcut ignores unrelated Cmd+key combos", () => { + expect( + matchShortcut(makeEvent({ key: "s", metaKey: true })) + ).toEqual(null); +}); + +test("matchShortcut ignores Cmd+Alt+Enter", () => { + expect( + matchShortcut(makeEvent({ altKey: true, key: "Enter", metaKey: true })) + ).toEqual(null); +}); diff --git a/tests/session-io.test.ts b/tests/session-io.test.ts new file mode 100644 index 0000000..6de919b --- /dev/null +++ b/tests/session-io.test.ts @@ -0,0 +1,201 @@ + + + + +/*** IMPORT ------------------------------------------- ***/ + +import { expect, test } from "vitest"; + +/*** UTILITY ------------------------------------------ ***/ + +import { + tabToExport, + validateSessionExport, + type SessionExport +} from "../source/library/state/session-io.ts"; + +type TabInput = Parameters<typeof tabToExport>[0]; + +function validExport(): SessionExport { + return { + exportedAt: "2026-04-24T00:00:00.000Z", + tabs: [ + { + headers: "{}", + operationName: "MyOp", + query: "query MyOp { hello }", + title: "MyOp", + variables: "{}" + } + ], + version: 1 + }; +} + +function isError(result: unknown): result is { error: string } { + return typeof result === "object" && + result !== null && + "error" in result; +} + +/*** TESTS -------------------------------------------- ***/ + +test("validateSessionExport round-trips a valid payload unchanged", () => { + const data = validExport(); + const result = validateSessionExport(data); + + expect(isError(result)).toBe(false); + expect(result).toEqual(data); +}); + +test("validateSessionExport rejects non-object input", () => { + expect(isError(validateSessionExport(null))).toBe(true); + expect(isError(validateSessionExport("nope"))).toBe(true); + expect(isError(validateSessionExport(42))).toBe(true); + expect(isError(validateSessionExport([]))).toBe(true); +}); + +test("validateSessionExport rejects wrong version", () => { + const zero = validateSessionExport({ ...validExport(), version: 0 }); + const two = validateSessionExport({ ...validExport(), version: 2 }); + const missing = validateSessionExport({ + exportedAt: "2026-04-24T00:00:00.000Z", + tabs: [] + }); + + expect(isError(zero)).toBe(true); + expect(isError(two)).toBe(true); + expect(isError(missing)).toBe(true); +}); + +test("validateSessionExport rejects missing or non-string exportedAt", () => { + const missing = validateSessionExport({ tabs: [], version: 1 }); + const wrongType = validateSessionExport({ exportedAt: 0, tabs: [], version: 1 }); + + expect(isError(missing)).toBe(true); + expect(isError(wrongType)).toBe(true); +}); + +test("validateSessionExport rejects non-array tabs", () => { + const result = validateSessionExport({ + exportedAt: "2026-04-24T00:00:00.000Z", + tabs: "not-an-array", + version: 1 + }); + + expect(isError(result)).toBe(true); +}); + +test("validateSessionExport rejects tab with non-string query", () => { + const result = validateSessionExport({ + exportedAt: "2026-04-24T00:00:00.000Z", + tabs: [ + { + headers: "{}", + operationName: null, + query: 42, + title: "t", + variables: "{}" + } + ], + version: 1 + }); + + expect(isError(result)).toBe(true); +}); + +test("validateSessionExport rejects tab with wrong-typed operationName", () => { + const result = validateSessionExport({ + exportedAt: "2026-04-24T00:00:00.000Z", + tabs: [ + { + headers: "{}", + operationName: 42, + query: "", + title: "t", + variables: "{}" + } + ], + version: 1 + }); + + expect(isError(result)).toBe(true); +}); + +test("validateSessionExport accepts null operationName", () => { + const result = validateSessionExport({ + exportedAt: "2026-04-24T00:00:00.000Z", + tabs: [ + { + headers: "{}", + operationName: null, + query: "", + title: "t", + variables: "{}" + } + ], + version: 1 + }); + + expect(isError(result)).toBe(false); +}); + +test("validateSessionExport rejects string field > 1 MB", () => { + const big = "x".repeat(1024 * 1024 + 1); + const result = validateSessionExport({ + exportedAt: "2026-04-24T00:00:00.000Z", + tabs: [ + { + headers: "{}", + operationName: null, + query: big, + title: "t", + variables: "{}" + } + ], + version: 1 + }); + + expect(isError(result)).toBe(true); +}); + +test("validateSessionExport rejects > 50 tabs", () => { + const tabs = Array.from({ length: 51 }, () => ({ + headers: "{}", + operationName: null, + query: "", + title: "t", + variables: "{}" + })); + const result = validateSessionExport({ + exportedAt: "2026-04-24T00:00:00.000Z", + tabs, + version: 1 + }); + + expect(isError(result)).toBe(true); +}); + +test("tabToExport strips id, result, operations, titleDirty", () => { + const tab: TabInput = { + headers: "{\"x\":1}", + id: "abc-123", + operationName: "MyOp", + operations: [{ name: "MyOp", type: "query" }], + query: "query MyOp { hello }", + result: "{\"data\":{}}", + streamIntervals: [], + timing: null, + title: "MyOp", + titleDirty: true, + variables: "{}" + }; + + expect(tabToExport(tab)).toEqual({ + headers: "{\"x\":1}", + operationName: "MyOp", + query: "query MyOp { hello }", + title: "MyOp", + variables: "{}" + }); +}); diff --git a/tests/timing.test.ts b/tests/timing.test.ts new file mode 100644 index 0000000..e2a0a06 --- /dev/null +++ b/tests/timing.test.ts @@ -0,0 +1,100 @@ + + + + +/*** IMPORT ------------------------------------------- ***/ + +import { expect, test } from "vitest"; + +/*** UTILITY ------------------------------------------ ***/ + +import type { Fetcher, FetcherResult } from "../source/library/fetcher/types.ts"; +import { SessionStore } from "../source/library/state/session.svelte.ts"; +import { createMemoryStorage } from "../source/library/state/storage.ts"; + +function delay(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/*** TESTS -------------------------------------------- ***/ + +test("run() populates timing for a one-shot fetcher", async () => { + const store = new SessionStore(createMemoryStorage()); + const tab = store.active; + + expect(tab).toBeDefined(); + + if (!tab) + return; + + const fetcher: Fetcher = () => Promise.resolve({ data: { hello: "world" } }); + const ok = await store.run(fetcher); + + expect(ok).toBe(true); + expect(tab.timing).not.toBeNull(); + + if (tab.timing === null) + return; + + expect(tab.timing.startMs <= tab.timing.firstByteMs).toBe(true); + expect(tab.timing.firstByteMs <= tab.timing.endMs).toBe(true); + expect(tab.timing.endMs - tab.timing.startMs >= 0).toBe(true); + expect(tab.streamIntervals.length).toEqual(0); +}); + +test("run() records intervals between async iterable payloads", async () => { + const store = new SessionStore(createMemoryStorage()); + const tab = store.active; + + expect(tab).toBeDefined(); + + if (!tab) + return; + + async function* stream(): AsyncGenerator<FetcherResult> { + yield { data: { n: 1 } }; + await delay(5); + yield { data: { n: 2 } }; + await delay(5); + yield { data: { n: 3 } }; + } + + const fetcher: Fetcher = () => stream(); + const ok = await store.run(fetcher); + + expect(ok).toBe(true); + expect(tab.streamIntervals.length).toEqual(2); + expect(tab.timing).not.toBeNull(); + + if (tab.timing === null) + return; + + expect(tab.timing.firstByteMs >= tab.timing.startMs).toBe(true); + expect(tab.timing.endMs >= tab.timing.firstByteMs).toBe(true); + + for (const delta of tab.streamIntervals) + expect(delta >= 0).toBe(true); +}); + +test("run() resets timing and streamIntervals on each invocation", async () => { + const store = new SessionStore(createMemoryStorage()); + const tab = store.active; + + expect(tab).toBeDefined(); + + if (!tab) + return; + + async function* stream(): AsyncGenerator<FetcherResult> { + yield { data: { n: 1 } }; + await delay(5); + yield { data: { n: 2 } }; + } + + await store.run(() => stream()); + expect(tab.streamIntervals.length).toEqual(1); + + await store.run(() => Promise.resolve({ data: {} })); + expect(tab.streamIntervals.length).toEqual(0); + expect(tab.timing).not.toBeNull(); +}); |