Skip to content
QueryWeave

Testing

Adapter

A deterministic memory adapter with its own back and forward stack.

Package
@queryweave/testing
Runtime
Any ECMAScript runtime
Depends on
@queryweave/core
Framework required
No
InstallQuick examplePackage: @queryweave/testing

The memory adapter holds the query string in memory, with a history stack you move through with back and forward. canGoBack and canGoForward tell you whether each move is possible. It notifies on every change, and an optional guard can refuse or redirect a write the way a router guard would.

Like every adapter, it leaves decoding, validation, and defaults to the model, and it adds nothing of its own. That makes it the reference implementation of the adapter contract: if a behavior shows up with the memory adapter, it came from the engine.

Terminal window
pnpm add -D @queryweave/testing
import { createQueryRuntime } from "@queryweave/core";
import { createMemoryQueryAdapter } from "@queryweave/testing";
import { expect, test } from "vitest";
test("resetting the page keeps the search term", async () => {
const adapter = createMemoryQueryAdapter({ initial: "?search=vue&page=3" });
const runtime = createQueryRuntime({ model: products, adapter });
await runtime.reset(["page"]);
expect(adapter.current()).toBe("search=vue");
expect(runtime.read().values.page).toBe(1);
});

adapter.current() returns the stored query as a string, which makes assertions read like the URL a user would see.

const adapter = createMemoryQueryAdapter({ initial: "?page=2" });
adapter.read(); // QueryOutput — what the runtime consumes
adapter.entries(); // the same entries, as a convenience
adapter.current(); // "page=2" — formatted, no leading "?"
adapter.push(entries); // adds a history entry
adapter.replace(entries); // rewrites the current one
adapter.back();
adapter.forward();
adapter.canGoBack();
adapter.canGoForward();
adapter.dispose();

The stack behaves like a browser’s, which is what makes push and replace testable without a browser:

test("replace does not grow the history", async () => {
const adapter = createMemoryQueryAdapter();
const runtime = createQueryRuntime({ model: products, adapter });
await runtime.update({ page: 2 }); // push
await runtime.update({ page: 3 }, { navigation: "replace" });
expect(adapter.canGoBack()).toBe(true);
adapter.back();
expect(adapter.canGoBack()).toBe(false); // only one entry was added
expect(runtime.read().values.page).toBe(1);
});

Pushing after going back truncates the forward entries, exactly as a browser does.

test("one transition notifies once", async () => {
const adapter = createMemoryQueryAdapter();
const runtime = createQueryRuntime({ model: products, adapter });
const seen: number[] = [];
runtime.subscribe((snapshot) => {
seen.push(snapshot.values.page);
});
await runtime.transaction((draft) => {
draft.search = "vue";
draft.page = 2;
});
expect(seen).toStrictEqual([2]);
});
test("a refused page change leaves the state alone", async () => {
const adapter = createMemoryQueryAdapter({
initial: "?page=2",
guard: (next) =>
next.some(([k, v]) => k === "page" && v === "9") ? { outcome: "refused" } : undefined,
});
const runtime = createQueryRuntime({ model: products, adapter });
const result = await runtime.update({ page: 9 });
expect(result.outcome).toBe("refused");
expect(runtime.read().values.page).toBe(2);
});

The memory adapter navigates synchronously, which real adapters do not. The runtime serializes transitions either way, so ordering assertions hold; timing assertions belong in the browser and router suites.

  • back at the start and forward at the end do nothing. They do not throw and do not notify.
  • After dispose, every mutating call throws. read and entries keep working.
  • Entries are copied on write, so a caller cannot mutate the stack afterwards.
  • The initial value accepts any QueryInput — a string, entries, or an object.