aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authornetop://ウィビ <paul@webb.page>2026-04-24 16:37:33 -0700
committernetop://ウィビ <paul@webb.page>2026-04-24 16:37:33 -0700
commit510fd8cbe53abb39cba2c7cbaaefcf2783dc0066 (patch)
tree8f753a33c475b285f2a297785d34cda3b0a8faed /tests
parent261f3bdb77799009344aab4a60686b7186ebd3b0 (diff)
downloadgraphiql-510fd8cbe53abb39cba2c7cbaaefcf2783dc0066.tar.gz
graphiql-510fd8cbe53abb39cba2c7cbaaefcf2783dc0066.zip
Implement v0.6-1.0: shortcuts, format, export/import, splitter, timing, APQ
- v0.6: matchShortcut + format(); Cmd+Shift+Enter/W/F + Cmd+Alt+arrows - v0.7: SessionStore.exportAll/importTabs with version-1 validator - v0.8: Splitter component + four resize handles persisted under layout.* - v0.10: createApqFetcher (HTTP-only) wrapping shared http-body helpers - Drop .svelte re-exports from index.ts for multi-entry JSR/npm publishing
Diffstat (limited to '')
-rw-r--r--tests/apq.test.ts151
-rw-r--r--tests/format.test.ts28
-rw-r--r--tests/keyboard.test.ts111
-rw-r--r--tests/session-io.test.ts201
-rw-r--r--tests/timing.test.ts100
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();
+});