From 510fd8cbe53abb39cba2c7cbaaefcf2783dc0066 Mon Sep 17 00:00:00 2001 From: "netop://ウィビ" Date: Fri, 24 Apr 2026 16:37:33 -0700 Subject: 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 --- PLAN.md | 96 +++++++- source/library/GraphiQL.svelte | 307 ++++++++++++++++++++++---- source/library/components/HistoryPanel.svelte | 106 ++++++++- source/library/components/ResultViewer.svelte | 51 ++++- source/library/components/Splitter.svelte | 123 +++++++++++ source/library/components/Toolbar.svelte | 5 + source/library/fetcher/apq.ts | 101 +++++++++ source/library/fetcher/http-body.ts | 31 +++ source/library/fetcher/http.ts | 19 +- source/library/graphql/format.ts | 22 ++ source/library/index.ts | 15 +- source/library/state/keyboard.ts | 50 +++++ source/library/state/session-io.ts | 127 +++++++++++ source/library/state/session.svelte.ts | 142 ++++++++++++ tests/apq.test.ts | 151 +++++++++++++ tests/format.test.ts | 28 +++ tests/keyboard.test.ts | 111 ++++++++++ tests/session-io.test.ts | 201 +++++++++++++++++ tests/timing.test.ts | 100 +++++++++ 19 files changed, 1710 insertions(+), 76 deletions(-) create mode 100644 source/library/components/Splitter.svelte create mode 100644 source/library/fetcher/apq.ts create mode 100644 source/library/fetcher/http-body.ts create mode 100644 source/library/graphql/format.ts create mode 100644 source/library/state/keyboard.ts create mode 100644 source/library/state/session-io.ts create mode 100644 tests/apq.test.ts create mode 100644 tests/format.test.ts create mode 100644 tests/keyboard.test.ts create mode 100644 tests/session-io.test.ts create mode 100644 tests/timing.test.ts diff --git a/PLAN.md b/PLAN.md index 25f806e..f97e7ac 100644 --- a/PLAN.md +++ b/PLAN.md @@ -88,14 +88,94 @@ Replace the stubs in `fetcher/sse.ts` and `fetcher/websocket.ts`. This keeps the plugin story type-safe without a bespoke registry. -### v0.6+ — Nice to have - -- Export/import session state as JSON (for sharing) -- Query formatting button (uses `graphql`'s `print()`) -- Keyboard shortcut for new tab (Cmd+T), close tab (Cmd+W), next/prev tab (Cmd+Opt+Right/Left) -- Split-pane resize handles (use CSS `resize: horizontal` or a lightweight splitter) -- Request/response timing display -- Persisted query support (APQ) +### v0.6 — Ergonomics (keyboard + format) + +**Keyboard shortcuts.** Extend the existing `onKeydown` handler in `GraphiQL.svelte` (already bound via ``). Add, in priority order: + +- `Cmd/Ctrl + Shift + Enter` — new tab (`session.addTab()`). We do **not** use `Cmd+T`; browsers intercept it and `preventDefault` is unreliable across hosts. Document this in README — if an embedder is running in Tauri/Electron they can remap to `Cmd+T` themselves. +- `Cmd/Ctrl + Shift + W` — close active tab (`session.closeTab(session.activeId)`). Same reasoning — `Cmd+W` is browser-owned. +- `Cmd/Ctrl + Alt + Right/Left` — next/prev tab. Wraps around end-to-start. No-op on a single tab. +- `Cmd/Ctrl + Shift + F` — format active query (see below). + +Extract the handler into `state/keyboard.ts` as a pure function `matchShortcut(event) → Action | null` so `tests/keyboard.test.ts` can cover matrix cases without a DOM. + +**Query formatting.** New file `graphql/format.ts` exporting `format(query: string): string` that calls `print(parse(query))` from `graphql`. On parse failure, return the original string unchanged — don't throw. Wire into: + +- A `Format` button in `Toolbar.svelte`, placed between the operation picker and `extras`. Disabled when the active tab query is empty or unparseable (check via `operations.length === 0 && query.trim().length > 0`? No — cheaper to attempt `format()` and compare; if equal to input *and* parse would have failed, disable. Actually simplest: always enable, no-op on unparseable). +- `Cmd/Ctrl + Shift + F` shortcut above. Route through `session.formatActive()` which sets `tab.query = format(tab.query)` and re-derives operations. + +Gotcha: `print()` strips comments. Document this — we are not going to keep a CST round-trip for v0.6. + +### v0.7 — Session portability + +**Export/import session JSON.** Extend `SessionStore` with: + +- `exportTab(id: string): TabExport | null` — strips `id`, `result`, `operations` (derivable), keeps `headers`, `operationName`, `query`, `title`, `variables`, and a version tag. +- `exportAll(): SessionExport` — `{ version: 1, exportedAt: ISO, tabs: TabExport[] }`. +- `importTabs(data: SessionExport, opts: { mode: "replace" | "append" }): ImportResult` — validates the `version` field and tab shape (lightweight hand-rolled check, no zod); rejects unknown versions with a descriptive error; returns `{ added: number, skipped: number, errors: string[] }`. + +Shape validation lives in `state/session-io.ts` so it can be unit tested without the store. Treat imported JSON as untrusted — reject if any string field exceeds 1 MB (covers accidental mega-pastes), cap tab count at 50 per import. + +**UI surface.** Two buttons in the History panel header (already has `Clear`) — `Export` and `Import`. Export downloads via `Blob` + object URL, filename `graphiql-session-{ISO}.json`. Import uses a hidden `` triggered by the button. Show an `ImportResult` toast-style row inline in the panel on completion; no toast library. + +### v0.8 — Layout resize + +**Splitter component.** `components/Splitter.svelte`, no external deps. Props: `orientation: "horizontal" | "vertical"`, `min: number`, `max: number`, `value: number`, `onChange: (v: number) => void`. Internal: `pointerdown` captures the pointer, `pointermove` computes delta against the parent element's bounding rect, `pointerup` releases capture. Writes to `value` via `onChange` on every move (consumer is expected to throttle via `$effect` if needed — store writes are already debounced). + +**Wire into `GraphiQL.svelte`.** Replace the fixed `grid-template-columns` / `grid-template-rows` with CSS custom properties driven by `$state`: + +- `--graphiql-left-width` (default `1fr`, resized via middle splitter between query column and result) +- `--graphiql-bottom-height` (default split between editor and variables/headers pane — currently `2fr auto 1fr`, change the `1fr` to `var(--graphiql-bottom-height, 1fr)`) +- `--graphiql-docs-width` (default `320px`, only present when docs open) +- `--graphiql-history-width` (default `260px`, only present when history open) + +Persist each to storage under `layout.{key}` keys, hydrated in the constructor. Storage accepts numbers; we re-stringify as `{n}px` or fractions depending on which axis. + +Three splitters total: between history/left, between left/right, between right/docs, and a horizontal one inside `.left` between the query editor and the variables/headers pane. Keep total splitter DOM cost at four elements. + +### v0.9 — Timing display + +**Instrumentation at the run boundary.** Modify `SessionStore.run()` only — don't touch the `Fetcher` signature. Record: + +- `tab.timing.startMs = performance.now()` before the fetcher call. +- `tab.timing.firstByteMs = performance.now()` on first payload (either the awaited object or the first iterator step). +- `tab.timing.endMs = performance.now()` on completion. +- For subscriptions: `tab.streamIntervals: number[]` — delta between each successive payload, capped at 500 entries per run. + +Add `timing` and `streamIntervals` fields to `Tab`. Render a small row under `ResultViewer` (not a footer snippet — that's for consumers) showing `→ 42ms · first byte 18ms` for one-shot, or `→ 12 messages · median 430ms` for streams. Compute the median inline; no helpers dir. + +Subscription streams don't have a single end; treat `endMs` as the time of the last payload and update it on each step so consumers see a rolling duration while streaming. + +**Do not** attempt to surface server-side trace extensions (Apollo Tracing, `response.extensions.tracing`) in v0.9 — that's a separate feature with its own UI. Document as future work. + +### v0.10 — Persisted queries (APQ) + +**Wrapper fetcher.** New file `fetcher/apq.ts`, exported as `createApqFetcher(inner: Fetcher, options?: ApqOptions): Fetcher`. Implements the Apollo Automatic Persisted Queries protocol: + +1. Compute `sha256Hash` of the query string with `globalThis.crypto.subtle.digest("SHA-256", ...)`. Cache per-query in a `Map` to avoid rehashing. +2. First attempt — send `{ operationName, variables, extensions: { persistedQuery: { version: 1, sha256Hash } } }` without the `query`. Wrap the inner fetcher by passing a synthetic `FetcherRequest` with `query: ""` and stash the hash in `headers["x-apq-hash"]`? No — cleaner: don't reuse `inner`, build the body ourselves. This means APQ needs its own HTTP fetcher variant or needs to extend the inner fetcher contract. + + **Decision:** APQ is HTTP-specific. Implement `createApqFetcher(options: FetcherOptions): Fetcher` that builds the body directly, bypassing `createHttpFetcher`. Share the header-merging logic via a small helper `fetcher/http-body.ts`. SSE/WS get no APQ support in v0.10 — document as out of scope. + +3. If response contains `{ errors: [{ message: "PersistedQueryNotFound", ... }] }`, retry with `{ query, operationName, variables, extensions: { persistedQuery: { version: 1, sha256Hash } } }`. +4. Cache the hash → query mapping in memory for the fetcher's lifetime; no disk persistence in v0.10 (servers already cache). + +`ApqOptions`: + +- Everything from `FetcherOptions` +- `disable?: boolean` — escape hatch for debugging, forces full query on every request. + +**Export.** Add `createApqFetcher` and `ApqOptions` to `source/library/index.ts`. No UI changes — this is consumer-facing plumbing. + +### v1.0 — Stabilization + +Not a feature stage. Before tagging v1.0: + +- README audit: every exported name has an example. +- `deno publish --dry-run` clean, no type errors. +- Every `*.svelte.ts` store has a companion `tests/*.test.ts`. +- Theme variables documented in one table in the README. +- One known-good end-to-end story: run against a real GraphQL endpoint (e.g., the Countries API) via an example app in `examples/` (optional, can skip if the README reproducer is enough). ## Publishing diff --git a/source/library/GraphiQL.svelte b/source/library/GraphiQL.svelte index 329dd6f..4250fd3 100644 --- a/source/library/GraphiQL.svelte +++ b/source/library/GraphiQL.svelte @@ -10,14 +10,17 @@ import HeadersEditor from "./components/HeadersEditor.svelte"; import HistoryPanel from "./components/HistoryPanel.svelte"; import ResultViewer from "./components/ResultViewer.svelte"; + import Splitter from "./components/Splitter.svelte"; import TabBar from "./components/TabBar.svelte"; import Toolbar from "./components/Toolbar.svelte"; import type { Extension } from "@codemirror/state"; import type { Fetcher } from "./fetcher/types.ts"; import { HistoryStore } from "./state/history.svelte.ts"; + import { matchShortcut } from "./state/keyboard.ts"; import { SchemaStore } from "./state/schema.svelte.ts"; import { SessionStore } from "./state/session.svelte.ts"; import type { SubscriptionMode, Tab } from "./state/session.svelte.ts"; + import { validateSessionExport } from "./state/session-io.ts"; import { createLocalStorage, createMemoryStorage } from "./state/storage.ts"; import type { Storage } from "./state/storage.ts"; @@ -59,10 +62,28 @@ session.updateQuery(session.active.id, initialQuery); let bottomPane = $state<"variables" | "headers">("variables"); + let bottomHeight = $state(resolvedStorage.get("layout.bottomHeight") ?? 35); + let centerEl = $state(null); let docsOpen = $state(resolvedStorage.get("docExplorer") ?? false); + let docsWidth = $state(resolvedStorage.get("layout.docsWidth") ?? 320); + let historyNotice = $state(null); let historyOpen = $state(resolvedStorage.get("historyPanel") ?? false); + let historyWidth = $state(resolvedStorage.get("layout.historyWidth") ?? 260); + let leftEl = $state(null); + let leftWidth = $state(resolvedStorage.get("layout.leftWidth") ?? 50); let running = $state(false); + let dragStartHistoryWidth = 0; + let dragStartDocsWidth = 0; + let dragStartLeftWidth = 0; + let dragStartLeftWidthPx = 0; + let dragStartBottomHeight = 0; + let dragStartBottomHeightPx = 0; + + function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); + } + $effect(() => { void session.tabs; void session.activeId; @@ -92,6 +113,22 @@ resolvedStorage.set("historyPanel", historyOpen); }); + $effect(() => { + resolvedStorage.set("layout.bottomHeight", bottomHeight); + }); + + $effect(() => { + resolvedStorage.set("layout.docsWidth", docsWidth); + }); + + $effect(() => { + resolvedStorage.set("layout.historyWidth", historyWidth); + }); + + $effect(() => { + resolvedStorage.set("layout.leftWidth", leftWidth); + }); + onMount(() => { schema.introspect(fetcher); }); @@ -149,6 +186,61 @@ session.overwriteActive(seed); } + function onExportSession() { + if (typeof globalThis.URL === "undefined") + return; + + if (typeof globalThis.document === "undefined") + return; + + const data = session.exportAll(); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = globalThis.document.createElement("a"); + a.download = `graphiql-session-${data.exportedAt}.json`; + a.href = url; + a.click(); + URL.revokeObjectURL(url); + } + + async function onImportSession(file: File) { + let text: string; + + try { + text = await file.text(); + } catch (err) { + historyNotice = `Import failed: ${String(err)}`; + return; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(text); + } catch (err) { + historyNotice = `Import failed: invalid JSON (${String(err)})`; + return; + } + + const valid = validateSessionExport(parsed); + + if ("error" in valid) { + historyNotice = `Import failed: ${valid.error}`; + return; + } + + const result = session.importTabs(valid, { mode: "append" }); + const parts = [`Imported ${result.added} tab${result.added === 1 ? "" : "s"}`]; + + if (result.skipped > 0) + parts.push(`skipped ${result.skipped}`); + + if (result.errors.length > 0) + parts.push(`${result.errors.length} error${result.errors.length === 1 ? "" : "s"}`); + + historyNotice = parts.join(", "); + } + function onQueryChange(value: string) { if (session.active) session.updateQuery(session.active.id, value); @@ -165,17 +257,96 @@ } function onKeydown(event: KeyboardEvent) { - const meta = event.metaKey || event.ctrlKey; + const action = matchShortcut(event); - if (meta && event.key === "Enter") { - event.preventDefault(); - run(); + if (!action) + return; + + event.preventDefault(); + + switch (action.type) { + case "closeTab": + session.closeTab(session.activeId); + break; + case "format": + session.formatActive(); + break; + case "newTab": + session.addTab(); + break; + case "nextTab": + session.nextTab(); + break; + case "prevTab": + session.prevTab(); + break; + case "run": + run(); + break; } } function onBeforeUnload() { session.persist(); } + + function onHistoryDragStart() { + dragStartHistoryWidth = historyWidth; + } + + function onHistoryDrag(dx: number) { + historyWidth = clamp(dragStartHistoryWidth + dx, 200, 500); + } + + function onHistoryKeyAdjust(delta: number) { + historyWidth = clamp(historyWidth + delta, 200, 500); + } + + function onDocsDragStart() { + dragStartDocsWidth = docsWidth; + } + + function onDocsDrag(dx: number) { + docsWidth = clamp(dragStartDocsWidth - dx, 240, 600); + } + + function onDocsKeyAdjust(delta: number) { + docsWidth = clamp(docsWidth - delta, 240, 600); + } + + function onLeftDragStart() { + dragStartLeftWidth = leftWidth; + dragStartLeftWidthPx = centerEl?.getBoundingClientRect().width ?? 0; + } + + function onLeftDrag(dx: number) { + if (dragStartLeftWidthPx === 0) + return; + + const percentDelta = (dx / dragStartLeftWidthPx) * 100; + leftWidth = clamp(dragStartLeftWidth + percentDelta, 20, 80); + } + + function onLeftKeyAdjust(delta: number) { + leftWidth = clamp(leftWidth + delta, 20, 80); + } + + function onBottomDragStart() { + dragStartBottomHeight = bottomHeight; + dragStartBottomHeightPx = leftEl?.getBoundingClientRect().height ?? 0; + } + + function onBottomDrag(_dx: number, dy: number) { + if (dragStartBottomHeightPx === 0) + return; + + const percentDelta = (dy / dragStartBottomHeightPx) * 100; + bottomHeight = clamp(dragStartBottomHeight - percentDelta, 15, 70); + } + + function onBottomKeyAdjust(delta: number) { + bottomHeight = clamp(bottomHeight - delta, 15, 70); + }
-
- History - {#if entries.length > 0} - +
+
+ History +
+ {#if onExport} + + {/if} + {#if onImport} + + + {/if} + {#if entries.length > 0} + + {/if} +
+
+ {#if notice} +
+ {notice} + {#if onDismissNotice} + + {/if} +
{/if}
diff --git a/source/library/components/ResultViewer.svelte b/source/library/components/ResultViewer.svelte index 083828d..d277ec8 100644 --- a/source/library/components/ResultViewer.svelte +++ b/source/library/components/ResultViewer.svelte @@ -2,14 +2,50 @@ import Editor from "./Editor.svelte"; import type { Extension } from "@codemirror/state"; import type { Snippet } from "svelte"; + import type { TabTiming } from "../state/session.svelte.ts"; type Props = { footer?: Snippet<[{ result: string }]>; + streamIntervals?: number[]; theme?: Extension; + timing?: TabTiming | null; value: string; }; - let { footer, theme, value }: Props = $props(); + let { + footer, + streamIntervals = [], + theme, + timing = null, + value + }: Props = $props(); + + function median(values: number[]): number { + if (values.length === 0) + return 0; + + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + + return sorted.length % 2 === 0 ? + (sorted[mid - 1] + sorted[mid]) / 2 : + sorted[mid]; + } + + let metadata = $derived.by(() => { + if (!timing) + return null; + + if (streamIntervals.length > 0) { + const medianMs = Math.round(median(streamIntervals)); + const messages = streamIntervals.length + 1; + return `→ ${messages} messages · median ${medianMs}ms`; + } + + const totalMs = Math.round(timing.endMs - timing.startMs); + const firstByteMs = Math.round(timing.firstByteMs - timing.startMs); + return `→ ${totalMs}ms · first byte ${firstByteMs}ms`; + }); function noop(_v: string) {} @@ -17,7 +53,7 @@ + + diff --git a/source/library/components/Toolbar.svelte b/source/library/components/Toolbar.svelte index 8c75668..9882b7d 100644 --- a/source/library/components/Toolbar.svelte +++ b/source/library/components/Toolbar.svelte @@ -8,6 +8,7 @@ docsOpen?: boolean; extras?: Snippet; historyOpen?: boolean; + onFormat?: () => void; onRun: () => void; onSelectOperation?: (name: string | null) => void; onToggleDocs?: () => void; @@ -24,6 +25,7 @@ docsOpen = false, extras, historyOpen = false, + onFormat, onRun, onSelectOperation, onToggleDocs, @@ -128,6 +130,9 @@ {/each} {/if} + {#if onFormat} + + {/if} {#if extras}{@render extras()}{/if} ⌘/Ctrl + Enter {#if schemaLoading} diff --git a/source/library/fetcher/apq.ts b/source/library/fetcher/apq.ts new file mode 100644 index 0000000..7f9f3c1 --- /dev/null +++ b/source/library/fetcher/apq.ts @@ -0,0 +1,101 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import { buildHeaders, postJson } from "./http-body.ts"; +import type { Fetcher, FetcherOptions } from "./types.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +export type ApqOptions = FetcherOptions & { + disable?: boolean; +}; + +export function createApqFetcher(options: ApqOptions): Fetcher { + const cache = new Map(); + const fetchImpl = options.fetch ?? globalThis.fetch; + + return async (req) => { + const headers = buildHeaders(options, req); + const sha256Hash = await getHash(cache, req.query); + + const extensions = { + persistedQuery: { + sha256Hash, + version: 1 + } + }; + + if (options.disable === true) { + return await postJson(fetchImpl, options.url, headers, { + extensions, + operationName: req.operationName, + query: req.query, + variables: req.variables + }); + } + + const firstResponse = await postJson(fetchImpl, options.url, headers, { + extensions, + operationName: req.operationName, + variables: req.variables + }); + + if (!isPersistedQueryNotFound(firstResponse)) + return firstResponse; + + return await postJson(fetchImpl, options.url, headers, { + extensions, + operationName: req.operationName, + query: req.query, + variables: req.variables + }); + }; +} + +/*** INTERNAL ----------------------------------------- ***/ + +async function getHash(cache: Map, query: string): Promise { + const cached = cache.get(query); + + if (cached !== undefined) + return cached; + + const hash = await sha256Hex(query); + cache.set(query, hash); + return hash; +} + +function isPersistedQueryNotFound(response: Record): boolean { + const errors = response.errors; + + if (!Array.isArray(errors)) + return false; + + for (const entry of errors) { + if (entry === null || typeof entry !== "object") + continue; + + const err = entry as Record; + + if (err.message === "PersistedQueryNotFound") + return true; + + const ext = err.extensions; + + if (ext !== null && typeof ext === "object") { + const code = (ext as Record).code; + + if (code === "PERSISTED_QUERY_NOT_FOUND") + return true; + } + } + + return false; +} + +async function sha256Hex(input: string): Promise { + const buf = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join(""); +} diff --git a/source/library/fetcher/http-body.ts b/source/library/fetcher/http-body.ts new file mode 100644 index 0000000..b079d47 --- /dev/null +++ b/source/library/fetcher/http-body.ts @@ -0,0 +1,31 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import type { FetcherOptions, FetcherRequest } from "./types.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +export function buildHeaders(options: FetcherOptions, req: FetcherRequest): Record { + return { + "Content-Type": "application/json", + ...options.headers, + ...req.headers + }; +} + +export async function postJson( + fetchImpl: typeof globalThis.fetch, + url: string, + headers: Record, + body: unknown +): Promise> { + const response = await fetchImpl(url, { + body: JSON.stringify(body), + headers, + method: "POST" + }); + + return await response.json(); +} diff --git a/source/library/fetcher/http.ts b/source/library/fetcher/http.ts index 3138226..64322f2 100644 --- a/source/library/fetcher/http.ts +++ b/source/library/fetcher/http.ts @@ -3,6 +3,7 @@ /*** UTILITY ------------------------------------------ ***/ +import { buildHeaders, postJson } from "./http-body.ts"; import type { Fetcher, FetcherOptions } from "./types.ts"; /*** EXPORT ------------------------------------------- ***/ @@ -11,20 +12,10 @@ export function createHttpFetcher(options: FetcherOptions): Fetcher { const fetchImpl = options.fetch ?? globalThis.fetch; return async (req) => { - const response = await fetchImpl(options.url, { - body: JSON.stringify({ - operationName: req.operationName, - query: req.query, - variables: req.variables - }), - headers: { - "Content-Type": "application/json", - ...options.headers, - ...req.headers - }, - method: "POST" + return await postJson(fetchImpl, options.url, buildHeaders(options, req), { + operationName: req.operationName, + query: req.query, + variables: req.variables }); - - return await response.json(); }; } diff --git a/source/library/graphql/format.ts b/source/library/graphql/format.ts new file mode 100644 index 0000000..905e25b --- /dev/null +++ b/source/library/graphql/format.ts @@ -0,0 +1,22 @@ + + + + +/*** IMPORT ------------------------------------------- ***/ + +import { parse, print } from "graphql"; + +/*** EXPORT ------------------------------------------- ***/ + +export function format(query: string): string { + const trimmed = query.trim(); + + if (!trimmed) + return query; + + try { + return print(parse(query)); + } catch { + return query; + } +} diff --git a/source/library/index.ts b/source/library/index.ts index 2bec6e4..d68955b 100644 --- a/source/library/index.ts +++ b/source/library/index.ts @@ -3,16 +3,20 @@ /*** EXPORT ------------------------------------------- ***/ +export { createApqFetcher } from "./fetcher/apq.ts"; export { createHttpFetcher } from "./fetcher/http.ts"; export { createLocalStorage, createMemoryStorage } from "./state/storage.ts"; export { createSseFetcher } from "./fetcher/sse.ts"; export { createWsFetcher } from "./fetcher/websocket.ts"; -export { default as GraphiQL } from "./GraphiQL.svelte"; +export { format } from "./graphql/format.ts"; export { HistoryStore } from "./state/history.svelte.ts"; export { lightTheme } from "./themes/light.ts"; +export { matchShortcut } from "./state/keyboard.ts"; export { SchemaStore } from "./state/schema.svelte.ts"; export { SessionStore } from "./state/session.svelte.ts"; +export { validateSessionExport } from "./state/session-io.ts"; +export type { ApqOptions } from "./fetcher/apq.ts"; export type { Extension } from "@codemirror/state"; export type { @@ -23,13 +27,20 @@ export type { } from "./fetcher/types.ts"; export type { HistoryEntry, HistoryInput } from "./state/history.svelte.ts"; +export type { + ImportResult, + SessionExport, + TabExport +} from "./state/session-io.ts"; export type { OperationInfo } from "./graphql/operations.ts"; +export type { ShortcutAction } from "./state/keyboard.ts"; export type { SseFetcherOptions } from "./fetcher/sse.ts"; export type { Storage } from "./state/storage.ts"; export type { RunOptions, SubscriptionMode, Tab, - TabSeed + TabSeed, + TabTiming } from "./state/session.svelte.ts"; export type { WsFetcherOptions } from "./fetcher/websocket.ts"; diff --git a/source/library/state/keyboard.ts b/source/library/state/keyboard.ts new file mode 100644 index 0000000..5f07b2c --- /dev/null +++ b/source/library/state/keyboard.ts @@ -0,0 +1,50 @@ + + + + +/*** EXPORT ------------------------------------------- ***/ + +export type ShortcutAction = + | { type: "closeTab" } + | { type: "format" } + | { type: "newTab" } + | { type: "nextTab" } + | { type: "prevTab" } + | { type: "run" }; + +export function matchShortcut(event: KeyboardEvent): ShortcutAction | null { + const meta = event.metaKey || event.ctrlKey; + + if (!meta) + return null; + + if (event.key === "Enter") { + if (event.shiftKey) + return { type: "newTab" }; + + if (!event.altKey) + return { type: "run" }; + + return null; + } + + if (event.shiftKey && !event.altKey) { + const key = event.key.toLowerCase(); + + if (key === "w") + return { type: "closeTab" }; + + if (key === "f") + return { type: "format" }; + } + + if (event.altKey && !event.shiftKey) { + if (event.key === "ArrowRight") + return { type: "nextTab" }; + + if (event.key === "ArrowLeft") + return { type: "prevTab" }; + } + + return null; +} diff --git a/source/library/state/session-io.ts b/source/library/state/session-io.ts new file mode 100644 index 0000000..a5e2ea9 --- /dev/null +++ b/source/library/state/session-io.ts @@ -0,0 +1,127 @@ + + + + +/*** UTILITY ------------------------------------------ ***/ + +import type { Tab } from "./session.svelte.ts"; + +const MAX_STRING_BYTES = 1024 * 1024; +const MAX_TABS = 50; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringTooLong(value: string): boolean { + return value.length > MAX_STRING_BYTES; +} + +function validateTab(raw: unknown, index: number): TabExport | string { + if (!isObject(raw)) + return `tabs[${index}]: not an object`; + + if (typeof raw.headers !== "string") + return `tabs[${index}].headers: not a string`; + + if (typeof raw.query !== "string") + return `tabs[${index}].query: not a string`; + + if (typeof raw.title !== "string") + return `tabs[${index}].title: not a string`; + + if (typeof raw.variables !== "string") + return `tabs[${index}].variables: not a string`; + + if (raw.operationName !== null && typeof raw.operationName !== "string") + return `tabs[${index}].operationName: not a string or null`; + + if (stringTooLong(raw.headers)) + return `tabs[${index}].headers: exceeds 1 MB`; + + if (stringTooLong(raw.query)) + return `tabs[${index}].query: exceeds 1 MB`; + + if (stringTooLong(raw.title)) + return `tabs[${index}].title: exceeds 1 MB`; + + if (stringTooLong(raw.variables)) + return `tabs[${index}].variables: exceeds 1 MB`; + + if (typeof raw.operationName === "string" && stringTooLong(raw.operationName)) + return `tabs[${index}].operationName: exceeds 1 MB`; + + return { + headers: raw.headers, + operationName: raw.operationName, + query: raw.query, + title: raw.title, + variables: raw.variables + }; +} + +/*** EXPORT ------------------------------------------- ***/ + +export type TabExport = { + headers: string; + operationName: string | null; + query: string; + title: string; + variables: string; +}; + +export type SessionExport = { + exportedAt: string; + tabs: TabExport[]; + version: 1; +}; + +export type ImportResult = { + added: number; + errors: string[]; + skipped: number; +}; + +export function tabToExport(tab: Tab): TabExport { + return { + headers: tab.headers, + operationName: tab.operationName, + query: tab.query, + title: tab.title, + variables: tab.variables + }; +} + +export function validateSessionExport(data: unknown): SessionExport | { error: string } { + if (!isObject(data)) + return { error: "not an object" }; + + if (data.version !== 1) + return { error: `unsupported version: ${String(data.version)}` }; + + if (typeof data.exportedAt !== "string") + return { error: "exportedAt: not a string" }; + + if (!Array.isArray(data.tabs)) + return { error: "tabs: not an array" }; + + if (data.tabs.length > MAX_TABS) + return { error: `tabs: exceeds ${MAX_TABS}` }; + + const tabs: TabExport[] = []; + + for (let i = 0; i < data.tabs.length; i++) { + const result = validateTab(data.tabs[i], i); + + if (typeof result === "string") + return { error: result }; + + tabs.push(result); + } + + return { + exportedAt: data.exportedAt, + tabs, + version: 1 + }; +} diff --git a/source/library/state/session.svelte.ts b/source/library/state/session.svelte.ts index 9345d29..76777e5 100644 --- a/source/library/state/session.svelte.ts +++ b/source/library/state/session.svelte.ts @@ -4,11 +4,18 @@ /*** UTILITY ------------------------------------------ ***/ import type { Fetcher, FetcherResult } from "../fetcher/types.ts"; +import { format } from "../graphql/format.ts"; import { deriveTitle, parseOperations, type OperationInfo } from "../graphql/operations.ts"; +import { + tabToExport, + type ImportResult, + type SessionExport, + type TabExport +} from "./session-io.ts"; import type { Storage } from "./storage.ts"; const STORAGE_KEY = "session"; @@ -26,6 +33,12 @@ type Snapshot = { /*** EXPORT ------------------------------------------- ***/ +export type TabTiming = { + endMs: number; + firstByteMs: number; + startMs: number; +}; + export type Tab = { headers: string; id: string; @@ -33,6 +46,8 @@ export type Tab = { operations: OperationInfo[]; query: string; result: string; + streamIntervals: number[]; + timing: TabTiming | null; title: string; titleDirty: boolean; variables: string; @@ -103,6 +118,85 @@ export class SessionStore { this.activeId = this.tabs[Math.max(0, idx - 1)].id; } + exportAll(): SessionExport { + return { + exportedAt: new Date().toISOString(), + tabs: this.tabs.map(tabToExport), + version: 1 + }; + } + + exportTab(id: string): TabExport | null { + const tab = this.tabs.find((t) => t.id === id); + + if (!tab) + return null; + + return tabToExport(tab); + } + + formatActive() { + const tab = this.active; + + if (!tab) + return; + + const next = format(tab.query); + + if (next === tab.query) + return; + + tab.query = next; + tab.operations = parseOperations(next); + + if (!tab.titleDirty) + tab.title = deriveTitle(next, tab.operations); + } + + importTabs(data: SessionExport, opts: { mode: "append" | "replace" }): ImportResult { + const errors: string[] = []; + const capped = data.tabs.slice(0, 50); + const skipped = data.tabs.length - capped.length; + + if (opts.mode === "replace") { + this.tabs = []; + + for (const t of capped) + this.tabs.push(this.#seeded(t)); + + if (this.tabs.length === 0) { + const fresh = this.#blank(); + this.tabs = [fresh]; + this.activeId = fresh.id; + } else { + this.activeId = this.tabs[0].id; + } + + return { added: capped.length, errors, skipped }; + } + + for (const t of capped) + this.tabs.push(this.#seeded(t)); + + if (capped.length > 0) + this.activeId = this.tabs[this.tabs.length - 1].id; + + return { added: capped.length, errors, skipped }; + } + + nextTab() { + if (this.tabs.length <= 1) + return; + + const idx = this.tabs.findIndex((t) => t.id === this.activeId); + + if (idx === -1) + return; + + const nextIdx = (idx + 1) % this.tabs.length; + this.activeId = this.tabs[nextIdx].id; + } + persist() { this.#storage.set(STORAGE_KEY, { activeId: this.activeId, @@ -110,6 +204,19 @@ export class SessionStore { }); } + prevTab() { + if (this.tabs.length <= 1) + return; + + const idx = this.tabs.findIndex((t) => t.id === this.activeId); + + if (idx === -1) + return; + + const prevIdx = (idx - 1 + this.tabs.length) % this.tabs.length; + this.activeId = this.tabs[prevIdx].id; + } + renameActive(title: string) { if (!this.active) return; @@ -137,6 +244,10 @@ export class SessionStore { const mode = options.subscriptionMode ?? "append"; const signal = options.signal; + tab.streamIntervals = []; + const startMs = performance.now(); + tab.timing = { endMs: startMs, firstByteMs: startMs, startMs }; + try { const variables = tab.variables.trim() ? JSON.parse(tab.variables) : {}; const headers = tab.headers.trim() ? JSON.parse(tab.headers) : {}; @@ -151,6 +262,8 @@ export class SessionStore { if (isAsyncIterable(result)) { tab.result = ""; const iterator = result[Symbol.asyncIterator](); + let firstByteRecorded = false; + let previousMs = startMs; try { while (true) { @@ -164,6 +277,23 @@ export class SessionStore { if (step.done) break; + const now = performance.now(); + + if (!firstByteRecorded) { + firstByteRecorded = true; + previousMs = now; + tab.timing = { ...tab.timing, endMs: now, firstByteMs: now }; + } else { + const delta = now - previousMs; + previousMs = now; + + if (tab.streamIntervals.length >= 500) + tab.streamIntervals.shift(); + + tab.streamIntervals = [...tab.streamIntervals, delta]; + tab.timing = { ...tab.timing, endMs: now }; + } + const payload = JSON.stringify(step.value, null, 2); if (mode === "append") { @@ -181,10 +311,14 @@ export class SessionStore { return true; } + const firstByteMs = performance.now(); + tab.timing = { ...tab.timing, firstByteMs }; tab.result = JSON.stringify(result, null, 2); + tab.timing = { ...tab.timing, endMs: performance.now() }; return true; } catch(err) { tab.result = JSON.stringify({ error: String(err) }, null, 2); + tab.timing = { ...tab.timing, endMs: performance.now() }; return false; } } @@ -201,6 +335,8 @@ export class SessionStore { tab.operationName = seed.operationName ?? null; tab.query = query; tab.result = ""; + tab.streamIntervals = []; + tab.timing = null; tab.variables = seed.variables ?? "{}"; if (seed.title && !tab.titleDirty) @@ -251,6 +387,8 @@ export class SessionStore { operations: [], query: "", result: "", + streamIntervals: [], + timing: null, title: "untitled", titleDirty: false, variables: "{}" @@ -265,6 +403,8 @@ export class SessionStore { operations: raw.operations ?? parseOperations(raw.query ?? ""), query: raw.query ?? "", result: raw.result ?? "", + streamIntervals: [], + timing: null, title: raw.title ?? "untitled", titleDirty: raw.titleDirty ?? raw.title !== "untitled", variables: raw.variables ?? "{}" @@ -283,6 +423,8 @@ export class SessionStore { operations, query, result: "", + streamIntervals: [], + timing: null, title, titleDirty: Boolean(seed.title), variables: seed.variables ?? "{}" 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; url: string }; + +function createStub(queue: Array>): { + 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; + 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 { + 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 { + const extensions = body.extensions as Record; + const persistedQuery = extensions.persistedQuery as Record; + 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[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 { + 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 { + 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 { + 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(); +}); -- cgit v1.2.3