From 8e64d4896b2b7c329910d1b6546fda8670bfd1b6 Mon Sep 17 00:00:00 2001
From: Owais Jamil
Date: Sat, 1 Aug 2026 13:42:07 -0500
Subject: [PATCH] feat: request dedup and client health/hardening
* add fonts
---
ROADMAP.md | 10 +-
TODO.md | 10 +-
bun.lock | 6 +
cypress.config.ts | 1 +
package.json | 2 +
src/App.vue | 3 +-
src/api/cache.ts | 187 ++++++++++++++++++
src/api/client.ts | 68 +++++--
src/api/errors.ts | 4 +
src/api/index.ts | 1 +
src/components/AppMenu.vue | 23 ++-
src/components/CoverageNotice.vue | 47 +++++
src/components/RequestState.vue | 150 ++++++++++++++
src/main.ts | 3 +
src/requests/index.ts | 2 +
src/requests/presentation.ts | 51 +++++
src/requests/useRouteRequest.ts | 116 +++++++++++
src/router/links.ts | 49 ++++-
src/theme/variables.css | 14 +-
src/views/DomainPage.vue | 5 +-
src/views/HomePage.vue | 15 +-
src/views/NotFoundPage.vue | 5 +-
src/views/SearchPage.vue | 3 +-
tests/e2e/specs/foundation.cy.ts | 11 ++
tests/unit/api/cache.spec.ts | 122 ++++++++++++
tests/unit/api/client.spec.ts | 47 ++++-
tests/unit/api/errors.spec.ts | 21 +-
tests/unit/domain-page.spec.ts | 4 +-
tests/unit/home-page.spec.ts | 8 +-
tests/unit/request-components.spec.ts | 58 ++++++
tests/unit/requests/use-route-request.spec.ts | 75 +++++++
tests/unit/router.spec.ts | 30 +++
32 files changed, 1091 insertions(+), 60 deletions(-)
create mode 100644 src/api/cache.ts
create mode 100644 src/components/CoverageNotice.vue
create mode 100644 src/components/RequestState.vue
create mode 100644 src/requests/index.ts
create mode 100644 src/requests/presentation.ts
create mode 100644 src/requests/useRouteRequest.ts
create mode 100644 tests/unit/api/cache.spec.ts
create mode 100644 tests/unit/request-components.spec.ts
create mode 100644 tests/unit/requests/use-route-request.spec.ts
diff --git a/ROADMAP.md b/ROADMAP.md
index 95eafc0..e087f1a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -51,10 +51,12 @@ the complete web/PWA experience; it does not reduce the product scope.
The repository is an Ionic Vue app using Vue 3, TypeScript, Vite 5, Vue Router,
Ionic 8, Capacitor 8, Vitest, Cypress, and ESLint. It has a typed Bobbin read
boundary, shared unit and browser-test fixtures, live Hurl contract checks, a
-Base16 theme engine, and the responsive application shell. Every planned domain
-has a stable deep-link route; later feature tickets replace the shell's route
-notices with live data. Capacitor has a starter app identifier and no checked-in
-Android or iOS project.
+Base16 theme engine, and the responsive application shell. The Bobbin boundary
+deduplicates concurrent reads with explicit stale times, and shared route state
+handles cancellation, retries, incomplete coverage, and distinct failures.
+Every planned domain has a stable deep-link route; later feature tickets replace
+the shell's route notices with live data. Capacitor has a starter app identifier
+and no checked-in Android or iOS project.
The API contract is documented in [docs/api.md](docs/api.md). Bobbin is a new,
read-only XRPC AppView whose hosted instance is `https://api.tangled.org`.
diff --git a/TODO.md b/TODO.md
index fa6255f..6b9d456 100644
--- a/TODO.md
+++ b/TODO.md
@@ -102,6 +102,8 @@ behavior, safe areas, and stable deep-link routing for every specified domain.
### T06 - Add coverage, caching, and shared request states
+**Status:** Complete
+
**What to build:** Add in-memory request deduplication, explicit stale times,
cursor guards, coverage notices, and shared loading/error/empty components.
@@ -109,10 +111,10 @@ cursor guards, coverage notices, and shared loading/error/empty components.
**Acceptance criteria:**
-- [ ] Route changes abort obsolete work and concurrent identical calls share one request.
-- [ ] `400`, `404`, `429`, `502`, `503`, offline, and malformed-data states remain distinct.
-- [ ] Retryable errors preserve existing content and respect `Retry-After` when present.
-- [ ] `ready: false` keeps partial indexed results visible with an explanation.
+- [x] Route changes abort obsolete work and concurrent identical calls share one request.
+- [x] `400`, `404`, `429`, `502`, `503`, offline, and malformed-data states remain distinct.
+- [x] Retryable errors preserve existing content and respect `Retry-After` when present.
+- [x] `ready: false` keeps partial indexed results visible with an explanation.
**Verification:** `bun run test:unit --run && bun run build`
diff --git a/bun.lock b/bun.lock
index 0853338..5c6abfd 100644
--- a/bun.lock
+++ b/bun.lock
@@ -14,6 +14,8 @@
"@capacitor/haptics": "8.0.2",
"@capacitor/keyboard": "8.0.5",
"@capacitor/status-bar": "8.0.3",
+ "@fontsource-variable/azeret-mono": "^5.3.0",
+ "@fontsource-variable/commissioner": "^5.3.0",
"@ionic/vue": "^8.0.0",
"@ionic/vue-router": "^8.0.0",
"ionicons": "^7.0.0",
@@ -322,6 +324,10 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
+ "@fontsource-variable/azeret-mono": ["@fontsource-variable/azeret-mono@5.3.0", "", {}, "sha512-nX76TwGHW53SoMMJoqb415uFqEQ3qnthL/bEeOOI2DjC7MntyIUVItQ45sVLe5+wG4gT0H5B7auAMa77p8Qc3Q=="],
+
+ "@fontsource-variable/commissioner": ["@fontsource-variable/commissioner@5.3.0", "", {}, "sha512-0IEvi8SO1slc5Pc9tA4OtA01I8UiCcEq/uh12oNjfHuABU9Lnz61l2V8o2cMPUUo3BAo5MbISVK2I/DSpBRw7Q=="],
+
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
diff --git a/cypress.config.ts b/cypress.config.ts
index 1c13364..ea4e151 100644
--- a/cypress.config.ts
+++ b/cypress.config.ts
@@ -9,6 +9,7 @@ const config = defineConfig({
videosFolder: 'tests/e2e/videos',
screenshotsFolder: 'tests/e2e/screenshots',
baseUrl: 'http://localhost:5173',
+ scrollBehavior: 'center',
},
})
diff --git a/package.json b/package.json
index 026eeeb..9fa64b1 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,8 @@
"@capacitor/haptics": "8.0.2",
"@capacitor/keyboard": "8.0.5",
"@capacitor/status-bar": "8.0.3",
+ "@fontsource-variable/azeret-mono": "^5.3.0",
+ "@fontsource-variable/commissioner": "^5.3.0",
"@ionic/vue": "^8.0.0",
"@ionic/vue-router": "^8.0.0",
"ionicons": "^7.0.0",
diff --git a/src/App.vue b/src/App.vue
index 07c9731..e335e78 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -15,6 +15,7 @@ import { IonApp, IonRouterOutlet, IonSplitPane } from '@ionic/vue'
import { onBeforeUnmount, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import AppMenu from './components/AppMenu.vue'
+import { links } from './router/links'
const router = useRouter()
let removeBackListener: (() => Promise) | undefined
@@ -26,7 +27,7 @@ onMounted(async () => {
if (typeof router.options.history.state.back === 'string') {
router.back()
} else if (router.currentRoute.value.path !== '/') {
- void router.replace('/')
+ void router.replace(links.home)
} else {
void CapacitorApp.exitApp()
}
diff --git a/src/api/cache.ts b/src/api/cache.ts
new file mode 100644
index 0000000..2f264a8
--- /dev/null
+++ b/src/api/cache.ts
@@ -0,0 +1,187 @@
+/** Explicit cache lifetimes for Bobbin query families. */
+export const STALE_TIMES = {
+ coverage: 15_000,
+ diagnostics: 60_000,
+ identity: 300_000,
+ list: 30_000,
+ record: 60_000,
+ search: 15_000,
+} as const
+
+export interface CacheRequestOptions {
+ force?: boolean
+ signal?: AbortSignal
+ staleTimeMs: number
+}
+
+interface PendingRequest {
+ controller: AbortController
+ promise: Promise
+ settled: boolean
+ subscribers: number
+}
+
+interface CacheEntry {
+ data?: T
+ hasData: boolean
+ pending?: PendingRequest
+ updatedAt: number
+}
+
+/**
+ * Small in-memory GET cache with request coalescing and subscriber-aware aborts.
+ * An underlying request is canceled only after every interested caller aborts.
+ */
+export class RequestCache {
+ readonly #entries = new Map>()
+ readonly #now: () => number
+
+ constructor(now: () => number = Date.now) {
+ this.#now = now
+ }
+
+ get(key: string, load: (signal: AbortSignal) => Promise, options: CacheRequestOptions): Promise {
+ if (options.staleTimeMs < 0) {
+ throw new RangeError('Cache stale times cannot be negative')
+ }
+ if (options.signal?.aborted) return Promise.reject(abortError())
+
+ const entry = this.#entry(key)
+ if (!options.force && entry.hasData && this.#now() - entry.updatedAt <= options.staleTimeMs) {
+ return Promise.resolve(entry.data as T)
+ }
+ if (entry.pending?.controller.signal.aborted) entry.pending = undefined
+
+ if (entry.pending === undefined) {
+ const controller = new AbortController()
+ const loadPromise = new Promise((resolve, reject) => {
+ try {
+ void load(controller.signal).then(resolve, reject)
+ } catch (error) {
+ reject(error)
+ }
+ })
+ const pending: PendingRequest = { controller, promise: loadPromise, settled: false, subscribers: 0 }
+ pending.promise = loadPromise
+ .then((data) => {
+ if (controller.signal.aborted) throw abortError()
+ entry.data = data
+ entry.hasData = true
+ entry.updatedAt = this.#now()
+ return data
+ })
+ .finally(() => {
+ pending.settled = true
+ if (entry.pending === pending) entry.pending = undefined
+ })
+ entry.pending = pending
+ }
+
+ return subscribe(entry.pending, options.signal)
+ }
+
+ peek(key: string): T | undefined {
+ const entry = this.#entries.get(key)
+ return entry?.hasData ? (entry.data as T) : undefined
+ }
+
+ invalidate(key: string): void {
+ const entry = this.#entries.get(key)
+ if (entry !== undefined) entry.updatedAt = Number.NEGATIVE_INFINITY
+ }
+
+ clear(): void {
+ for (const entry of this.#entries.values()) entry.pending?.controller.abort()
+ this.#entries.clear()
+ }
+
+ #entry(key: string): CacheEntry {
+ const existing = this.#entries.get(key)
+ if (existing !== undefined) return existing as CacheEntry
+
+ const entry: CacheEntry = { hasData: false, updatedAt: Number.NEGATIVE_INFINITY }
+ this.#entries.set(key, entry)
+ return entry
+ }
+}
+
+/** Tracks completed and active cursors so pagination cannot append a page twice. */
+export class CursorGuard {
+ readonly #active = new Set()
+ readonly #completed = new Set()
+
+ start(cursor?: string): boolean {
+ const key = cursorKey(cursor)
+ if (this.#active.has(key) || this.#completed.has(key)) return false
+ this.#active.add(key)
+ return true
+ }
+
+ complete(cursor: string | undefined, nextCursor?: string): string | undefined {
+ const key = cursorKey(cursor)
+ this.#active.delete(key)
+ this.#completed.add(key)
+
+ return nextCursor === undefined || this.#completed.has(cursorKey(nextCursor)) ? undefined : nextCursor
+ }
+
+ fail(cursor?: string): void {
+ this.#active.delete(cursorKey(cursor))
+ }
+
+ reset(): void {
+ this.#active.clear()
+ this.#completed.clear()
+ }
+}
+
+/** Produces the same cache key for objects whose keys have a different insertion order. */
+export function createRequestKey(nsid: string, parameters: unknown): string {
+ return `${nsid}:${stableSerialize(parameters)}`
+}
+
+function subscribe(pending: PendingRequest, signal?: AbortSignal): Promise {
+ if (signal?.aborted) return Promise.reject(abortError())
+ pending.subscribers += 1
+
+ return new Promise((resolve, reject) => {
+ let settled = false
+ const finish = (result: { data: T } | { error: unknown }) => {
+ if (settled) return
+ settled = true
+ signal?.removeEventListener('abort', onAbort)
+ pending.subscribers -= 1
+ if (pending.subscribers === 0 && !pending.settled && !pending.controller.signal.aborted)
+ pending.controller.abort()
+ if ('data' in result) resolve(result.data)
+ else reject(result.error)
+ }
+ const onAbort = () => finish({ error: abortError() })
+
+ signal?.addEventListener('abort', onAbort, { once: true })
+ void pending.promise.then(
+ (data) => finish({ data }),
+ (error: unknown) => finish({ error }),
+ )
+ })
+}
+
+function stableSerialize(value: unknown): string {
+ if (Array.isArray(value)) return `[${value.map(stableSerialize).join(',')}]`
+ if (typeof value === 'object' && value !== null) {
+ return `{${Object.entries(value)
+ .filter(([, item]) => item !== undefined)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, item]) => `${JSON.stringify(key)}:${stableSerialize(item)}`)
+ .join(',')}}`
+ }
+ return JSON.stringify(value)
+}
+
+function cursorKey(cursor?: string): string {
+ return cursor ?? '__first_page__'
+}
+
+function abortError(): DOMException {
+ return new DOMException('Canceled', 'AbortError')
+}
diff --git a/src/api/client.ts b/src/api/client.ts
index d2656ef..4dcb574 100644
--- a/src/api/client.ts
+++ b/src/api/client.ts
@@ -20,6 +20,7 @@ import {
} from './contracts'
import type { BobbinCoverage } from './contracts'
import { BobbinError, errorFromException, errorFromResponse } from './errors'
+import { createRequestKey, RequestCache, STALE_TIMES } from './cache'
/** A Bobbin record view after its embedded value has passed schema validation. */
export interface ValidatedRecordView {
@@ -36,12 +37,14 @@ export interface CursorPage {
/** Constructor options for a configurable, testable Bobbin boundary. */
export interface BobbinClientOptions {
+ cache?: RequestCache
service?: string | URL
fetch?: typeof globalThis.fetch
}
/** Options shared by abortable Bobbin requests. */
export interface RequestOptions {
+ cache?: 'default' | 'reload'
signal?: AbortSignal
}
@@ -83,16 +86,20 @@ interface BobbinSearchResponse {
*/
export class BobbinClient {
readonly service: string
+ readonly #cache: RequestCache
readonly #rpc: Client
constructor(options: BobbinClientOptions = {}) {
this.service = normalizeBobbinService(options.service ?? DEFAULT_BOBBIN_SERVICE)
+ this.#cache = options.cache ?? new RequestCache()
this.#rpc = new Client({ handler: simpleFetchHandler({ service: this.service, fetch: options.fetch }) })
}
/** Returns Bobbin's current Hydrant ingestion coverage. */
getCoverage(options: RequestOptions = {}): Promise {
- return this.#request(() => this.#rpc.call(bobbinCoverageSchema, options))
+ return this.#cached('sh.tangled.bobbin.getCoverage', {}, options, STALE_TIMES.coverage, (signal) =>
+ this.#rpc.call(bobbinCoverageSchema, { signal }),
+ )
}
/** Resolves a handle or DID through Microcosm's typed identity query. */
@@ -100,8 +107,12 @@ export class BobbinClient {
identifier: ComBadExampleIdentityResolveMiniDoc.$params['identifier'],
options: RequestOptions = {},
): Promise {
- return this.#request(() =>
- this.#rpc.call(ComBadExampleIdentityResolveMiniDoc, { params: { identifier }, signal: options.signal }),
+ return this.#cached(
+ 'com.bad-example.identity.resolveMiniDoc',
+ { identifier },
+ options,
+ STALE_TIMES.identity,
+ (signal) => this.#rpc.call(ComBadExampleIdentityResolveMiniDoc, { params: { identifier }, signal }),
)
}
@@ -110,8 +121,8 @@ export class BobbinClient {
actor: ShTangledActorGetProfile.$params['actor'],
options: RequestOptions = {},
): Promise> {
- const view = await this.#request(() =>
- this.#rpc.call(ShTangledActorGetProfile, { params: { actor }, signal: options.signal }),
+ const view = await this.#cached('sh.tangled.actor.getProfile', { actor }, options, STALE_TIMES.record, (signal) =>
+ this.#rpc.call(ShTangledActorGetProfile, { params: { actor }, signal }),
)
return validateRecordView(view, ShTangledActorProfile.mainSchema, 'actor profile')
@@ -122,8 +133,8 @@ export class BobbinClient {
repo: ShTangledRepoGetRepo.$params['repo'],
options: RequestOptions = {},
): Promise> {
- const view = await this.#request(() =>
- this.#rpc.call(ShTangledRepoGetRepo, { params: { repo }, signal: options.signal }),
+ const view = await this.#cached('sh.tangled.repo.getRepo', { repo }, options, STALE_TIMES.record, (signal) =>
+ this.#rpc.call(ShTangledRepoGetRepo, { params: { repo }, signal }),
)
return validateRecordView(view, ShTangledRepo.mainSchema, 'repository')
@@ -134,11 +145,9 @@ export class BobbinClient {
subject: ShTangledRepoListRepos.$params['subject'],
options: ListReposOptions = {},
): Promise>> {
- const data = await this.#request(() =>
- this.#rpc.call(ShTangledRepoListRepos, {
- params: { subject, cursor: options.cursor, limit: options.limit, order: options.order },
- signal: options.signal,
- }),
+ const parameters = { subject, cursor: options.cursor, limit: options.limit, order: options.order }
+ const data = await this.#cached('sh.tangled.repo.listRepos', parameters, options, STALE_TIMES.list, (signal) =>
+ this.#rpc.call(ShTangledRepoListRepos, { params: parameters, signal }),
)
return {
@@ -159,7 +168,9 @@ export class BobbinClient {
if (!is(ShTangledSearchQuery.mainSchema.params, params)) {
throw new BobbinError('invalid-request', 'The search parameters are invalid')
}
- const data = await this.#request(() => this.#rpc.get('sh.tangled.search.query', { params, signal: options.signal }))
+ const data = await this.#cached('sh.tangled.search.query', params, options, STALE_TIMES.search, (signal) =>
+ this.#rpc.get('sh.tangled.search.query', { params, signal }),
+ )
const validated = validateSearchResponse(data)
return {
@@ -183,21 +194,44 @@ export class BobbinClient {
/** Queries a knot's public owner through Bobbin's documented parameter overlay. */
getKnotOwner(knot: string, options: RequestOptions = {}) {
- return this.#request(() => this.#rpc.call(bobbinKnotOwnerSchema, { params: { knot }, signal: options.signal }))
+ return this.#cached('sh.tangled.owner', { knot }, options, STALE_TIMES.diagnostics, (signal) =>
+ this.#rpc.call(bobbinKnotOwnerSchema, { params: { knot }, signal }),
+ )
}
/** Queries a knot's public version through Bobbin's documented parameter overlay. */
getKnotVersion(knot: string, options: RequestOptions = {}) {
- return this.#request(() => this.#rpc.call(bobbinKnotVersionSchema, { params: { knot }, signal: options.signal }))
+ return this.#cached('sh.tangled.knot.version', { knot }, options, STALE_TIMES.diagnostics, (signal) =>
+ this.#rpc.call(bobbinKnotVersionSchema, { params: { knot }, signal }),
+ )
}
/** Lists a knot's public keys through Bobbin's documented parameter overlay. */
listKnotKeys(knot: string, params: { cursor?: string; limit?: number } = {}, options: RequestOptions = {}) {
- return this.#request(() =>
- this.#rpc.call(bobbinKnotListKeysSchema, { params: { knot, ...params }, signal: options.signal }),
+ const parameters = { knot, ...params }
+ return this.#cached('sh.tangled.knot.listKeys', parameters, options, STALE_TIMES.list, (signal) =>
+ this.#rpc.call(bobbinKnotListKeysSchema, { params: parameters, signal }),
)
}
+ async #cached>(
+ nsid: string,
+ parameters: unknown,
+ options: RequestOptions,
+ staleTimeMs: number,
+ request: (signal: AbortSignal) => Promise,
+ ): Promise> {
+ try {
+ return await this.#cache.get(
+ createRequestKey(nsid, parameters),
+ (signal) => this.#request(() => request(signal)),
+ { force: options.cache === 'reload', signal: options.signal, staleTimeMs },
+ )
+ } catch (error) {
+ throw errorFromException(error)
+ }
+ }
+
async #request>(
request: () => Promise,
): Promise> {
diff --git a/src/api/errors.ts b/src/api/errors.ts
index 4bf218c..c77a0ca 100644
--- a/src/api/errors.ts
+++ b/src/api/errors.ts
@@ -6,6 +6,7 @@ export type BobbinErrorKind =
| 'invalid-request'
| 'malformed-response'
| 'network'
+ | 'offline'
| 'not-found'
| 'rate-limited'
| 'service-unavailable'
@@ -71,6 +72,9 @@ export function errorFromException(error: unknown): BobbinError {
if (isAbortError(error)) {
return new BobbinError('aborted', 'The Bobbin request was canceled', { cause: error })
}
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) {
+ return new BobbinError('offline', 'This device is offline', { cause: error })
+ }
return new BobbinError('network', 'Bobbin could not be reached', { cause: error })
}
diff --git a/src/api/index.ts b/src/api/index.ts
index 07da8e7..2c76da8 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -1,3 +1,4 @@
export * from './client'
+export * from './cache'
export * from './contracts'
export * from './errors'
diff --git a/src/components/AppMenu.vue b/src/components/AppMenu.vue
index 40b0692..adecc66 100644
--- a/src/components/AppMenu.vue
+++ b/src/components/AppMenu.vue
@@ -13,9 +13,9 @@
@@ -38,6 +38,7 @@
@@ -96,6 +97,7 @@ function search(): void {
margin: 0 0 var(--space-3);
color: var(--app-accent);
font-size: var(--text-sm);
+ font-family: var(--font-mono);
font-weight: 700;
}
@@ -207,6 +209,11 @@ function search(): void {
color: var(--app-text-muted);
}
+.home-page__path-list span {
+ font-family: var(--font-mono);
+ font-size: var(--text-sm);
+}
+
.home-page__path-list strong {
font-family: var(--font-display);
font-size: var(--text-lg);
diff --git a/src/views/NotFoundPage.vue b/src/views/NotFoundPage.vue
index 54a1f39..f6e10e2 100644
--- a/src/views/NotFoundPage.vue
+++ b/src/views/NotFoundPage.vue
@@ -10,8 +10,8 @@
>.
- Return home
- Search Tangled
+ Return home
+ Search Tangled
@@ -20,6 +20,7 @@
diff --git a/tests/e2e/specs/foundation.cy.ts b/tests/e2e/specs/foundation.cy.ts
index 253fb6e..8bd38ae 100644
--- a/tests/e2e/specs/foundation.cy.ts
+++ b/tests/e2e/specs/foundation.cy.ts
@@ -15,6 +15,17 @@ describe('Twisted foundation', () => {
cy.wait('@sh.tangled.bobbin.getCoverage')
})
+ it('loads the self-hosted variable typefaces', () => {
+ cy.visit('/')
+ cy.document().then(async (document) => {
+ await document.fonts.ready
+ expect(document.fonts.check('16px "Commissioner Variable"')).to.equal(true)
+ expect(document.fonts.check('16px "Azeret Mono Variable"')).to.equal(true)
+ })
+ cy.get('h1').should('have.css', 'font-family').and('contain', 'Commissioner Variable')
+ cy.get('.home-page__eyebrow').should('have.css', 'font-family').and('contain', 'Azeret Mono Variable')
+ })
+
it('persists theme and service settings independently', () => {
cy.visit('/settings')
cy.contains('label', 'Catppuccin Latte').click()
diff --git a/tests/unit/api/cache.spec.ts b/tests/unit/api/cache.spec.ts
new file mode 100644
index 0000000..c0fcd20
--- /dev/null
+++ b/tests/unit/api/cache.spec.ts
@@ -0,0 +1,122 @@
+import { CursorGuard, RequestCache, createRequestKey } from '@/api'
+import { describe, expect, test, vi } from 'vitest'
+
+function deferred() {
+ let resolve!: (value: T) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise
+ reject = rejectPromise
+ })
+ return { promise, reject, resolve }
+}
+
+describe('RequestCache', () => {
+ test('shares one in-flight request between identical callers', async () => {
+ const pending = deferred()
+ const load = vi.fn(() => pending.promise)
+ const cache = new RequestCache()
+
+ const first = cache.get('same', load, { staleTimeMs: 1_000 })
+ const second = cache.get('same', load, { staleTimeMs: 1_000 })
+ pending.resolve('result')
+
+ await expect(Promise.all([first, second])).resolves.toEqual(['result', 'result'])
+ expect(load).toHaveBeenCalledOnce()
+ })
+
+ test('uses fresh data and reloads it after the explicit stale time', async () => {
+ let now = 1_000
+ const load = vi.fn().mockResolvedValueOnce('first').mockResolvedValueOnce('second')
+ const cache = new RequestCache(() => now)
+
+ await expect(cache.get('timed', load, { staleTimeMs: 100 })).resolves.toBe('first')
+ now = 1_100
+ await expect(cache.get('timed', load, { staleTimeMs: 100 })).resolves.toBe('first')
+ now = 1_101
+ await expect(cache.get('timed', load, { staleTimeMs: 100 })).resolves.toBe('second')
+ expect(load).toHaveBeenCalledTimes(2)
+ })
+
+ test('keeps shared work alive while another subscriber still needs it', async () => {
+ const pending = deferred()
+ let requestSignal!: AbortSignal
+ const cache = new RequestCache()
+ const firstController = new AbortController()
+ const secondController = new AbortController()
+ const load = (signal: AbortSignal) => {
+ requestSignal = signal
+ return pending.promise
+ }
+
+ const first = cache.get('shared', load, { signal: firstController.signal, staleTimeMs: 0 })
+ const second = cache.get('shared', load, { signal: secondController.signal, staleTimeMs: 0 })
+ firstController.abort()
+
+ await expect(first).rejects.toMatchObject({ name: 'AbortError' })
+ expect(requestSignal.aborted).toBe(false)
+ pending.resolve('still useful')
+ await expect(second).resolves.toBe('still useful')
+ })
+
+ test('aborts underlying work after every subscriber leaves', async () => {
+ const cache = new RequestCache()
+ const controller = new AbortController()
+ let requestSignal!: AbortSignal
+ const load = (signal: AbortSignal) => {
+ requestSignal = signal
+ return new Promise((_resolve, reject) => {
+ signal.addEventListener('abort', () => reject(new DOMException('Canceled', 'AbortError')), { once: true })
+ })
+ }
+
+ const request = cache.get('alone', load, { signal: controller.signal, staleTimeMs: 0 })
+ controller.abort()
+
+ await expect(request).rejects.toMatchObject({ name: 'AbortError' })
+ expect(requestSignal.aborted).toBe(true)
+ })
+
+ test('does not cache a result returned after every subscriber aborted', async () => {
+ const abandoned = deferred()
+ const controller = new AbortController()
+ const load = vi
+ .fn()
+ .mockImplementationOnce(() => abandoned.promise)
+ .mockResolvedValueOnce('current')
+ const cache = new RequestCache()
+
+ const request = cache.get('abandoned', load, { signal: controller.signal, staleTimeMs: 1_000 })
+ controller.abort()
+ await expect(request).rejects.toMatchObject({ name: 'AbortError' })
+ abandoned.resolve('obsolete')
+ await Promise.resolve()
+ await Promise.resolve()
+
+ await expect(cache.get('abandoned', load, { staleTimeMs: 1_000 })).resolves.toBe('current')
+ expect(load).toHaveBeenCalledTimes(2)
+ })
+
+ test('normalizes parameter key order', () => {
+ expect(createRequestKey('query', { b: 2, a: 1, missing: undefined })).toBe(
+ createRequestKey('query', { a: 1, b: 2 }),
+ )
+ })
+})
+
+describe('CursorGuard', () => {
+ test('blocks active, completed, and cyclic cursors while allowing failed pages to retry', () => {
+ const guard = new CursorGuard()
+
+ expect(guard.start()).toBe(true)
+ expect(guard.start()).toBe(false)
+ expect(guard.complete(undefined, 'next')).toBe('next')
+ expect(guard.start()).toBe(false)
+ expect(guard.start('next')).toBe(true)
+ expect(guard.complete('next', 'next')).toBeUndefined()
+
+ expect(guard.start('retry')).toBe(true)
+ guard.fail('retry')
+ expect(guard.start('retry')).toBe(true)
+ })
+})
diff --git a/tests/unit/api/client.spec.ts b/tests/unit/api/client.spec.ts
index b70624f..429a72f 100644
--- a/tests/unit/api/client.spec.ts
+++ b/tests/unit/api/client.spec.ts
@@ -64,15 +64,54 @@ describe('BobbinClient', () => {
test('maps aborts separately from network failures', async () => {
const controller = new AbortController()
+ let requestSignal!: AbortSignal
const fetch = vi.fn().mockImplementation((_input, init) => {
- expect(init?.signal).toBe(controller.signal)
- return Promise.reject(new DOMException('Canceled', 'AbortError'))
+ requestSignal = init?.signal as AbortSignal
+ return new Promise((_resolve, reject) => {
+ requestSignal.addEventListener('abort', () => reject(new DOMException('Canceled', 'AbortError')), {
+ once: true,
+ })
+ })
})
const client = new BobbinClient({ fetch })
+ const request = client.getCoverage({ signal: controller.signal })
+ controller.abort()
- await expect(client.getCoverage({ signal: controller.signal })).rejects.toEqual(
- expect.objectContaining>({ kind: 'aborted' }),
+ await expect(request).rejects.toEqual(expect.objectContaining>({ kind: 'aborted' }))
+ expect(requestSignal.aborted).toBe(true)
+ })
+
+ test('deduplicates concurrent identical Bobbin calls', async () => {
+ let resolveFetch!: (response: Response) => void
+ const fetch = vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve
+ }),
)
+ const client = new BobbinClient({ fetch })
+ const first = client.getCoverage()
+ const second = client.getCoverage()
+ resolveFetch(jsonResponse({ ready: true, eventsProcessed: 100, lastCursor: 120 }))
+
+ await expect(Promise.all([first, second])).resolves.toEqual([
+ { ready: true, eventsProcessed: 100, lastCursor: 120 },
+ { ready: true, eventsProcessed: 100, lastCursor: 120 },
+ ])
+ expect(fetch).toHaveBeenCalledOnce()
+ })
+
+ test('reloads a cached query only when requested', async () => {
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse({ ready: true, eventsProcessed: 100, lastCursor: 120 }))
+ .mockResolvedValueOnce(jsonResponse({ ready: true, eventsProcessed: 101, lastCursor: 121 }))
+ const client = new BobbinClient({ fetch })
+
+ await client.getCoverage()
+ await client.getCoverage()
+ await expect(client.getCoverage({ cache: 'reload' })).resolves.toMatchObject({ eventsProcessed: 101 })
+ expect(fetch).toHaveBeenCalledTimes(2)
})
test('adds Bobbin knot proxy parameters without copying generated outputs', async () => {
diff --git a/tests/unit/api/errors.spec.ts b/tests/unit/api/errors.spec.ts
index ddad7ce..6b58afa 100644
--- a/tests/unit/api/errors.spec.ts
+++ b/tests/unit/api/errors.spec.ts
@@ -1,5 +1,5 @@
-import { describe, expect, test } from 'vitest'
-import { errorFromResponse } from '@/api'
+import { describe, expect, test, vi } from 'vitest'
+import { errorFromException, errorFromResponse } from '@/api'
describe('errorFromResponse', () => {
test.each([
@@ -14,4 +14,21 @@ describe('errorFromResponse', () => {
expect(error).toMatchObject({ kind, status })
})
+
+ test('parses an HTTP-date Retry-After value', () => {
+ vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-08-01T12:00:00Z'))
+ const error = errorFromResponse({
+ status: 429,
+ headers: new Headers({ 'retry-after': 'Sat, 01 Aug 2026 12:00:05 GMT' }),
+ data: { error: 'RateLimitExceeded' },
+ })
+
+ expect(error.retryAfterMs).toBe(5_000)
+ })
+})
+
+test('distinguishes an offline device from another network failure', () => {
+ vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
+
+ expect(errorFromException(new TypeError('Failed to fetch'))).toMatchObject({ kind: 'offline' })
})
diff --git a/tests/unit/domain-page.spec.ts b/tests/unit/domain-page.spec.ts
index 4a57291..ff9b159 100644
--- a/tests/unit/domain-page.spec.ts
+++ b/tests/unit/domain-page.spec.ts
@@ -10,8 +10,8 @@ describe('DomainPage', () => {
component: DomainPage,
meta: { title: 'Profile', parameter: 'actor', requirement: 'actor' },
},
- { path: '/search', component: { template: '' } },
- { path: '/', component: { template: '' } },
+ { path: '/search', name: 'search', component: { template: '' } },
+ { path: '/', name: 'home', component: { template: '' } },
])
expect(wrapper.get('#unsupported-heading').text()).toBe('This identifier is not supported')
diff --git a/tests/unit/home-page.spec.ts b/tests/unit/home-page.spec.ts
index f3ca683..ed85758 100644
--- a/tests/unit/home-page.spec.ts
+++ b/tests/unit/home-page.spec.ts
@@ -7,10 +7,10 @@ describe('HomePage', () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const emptyPage = { template: '' }
const wrapper = await mountIonicRoute(HomePage, '/home', [
- { path: '/home', component: HomePage },
- { path: '/profiles', component: emptyPage },
- { path: '/repositories', component: emptyPage },
- { path: '/infrastructure', component: emptyPage },
+ { path: '/home', name: 'home', component: HomePage },
+ { path: '/profiles', name: 'profiles', component: emptyPage },
+ { path: '/repositories', name: 'repositories', component: emptyPage },
+ { path: '/infrastructure', name: 'infrastructure', component: emptyPage },
])
expect(wrapper.get('h1').text()).toBe('Follow the thread.')
diff --git a/tests/unit/request-components.spec.ts b/tests/unit/request-components.spec.ts
new file mode 100644
index 0000000..c928c63
--- /dev/null
+++ b/tests/unit/request-components.spec.ts
@@ -0,0 +1,58 @@
+import { BobbinError } from '@/api'
+import CoverageNotice from '@/components/CoverageNotice.vue'
+import RequestState from '@/components/RequestState.vue'
+import { mount } from '@vue/test-utils'
+import { describe, expect, test } from 'vitest'
+
+describe('RequestState', () => {
+ test.each([
+ ['invalid-request', 'Check this request'],
+ ['not-found', 'Record not found'],
+ ['rate-limited', 'Bobbin asked us to slow down'],
+ ['upstream-unavailable', 'The upstream service is unavailable'],
+ ['service-unavailable', 'Bobbin is unavailable'],
+ ['offline', 'You are offline'],
+ ['malformed-response', 'Bobbin returned unexpected data'],
+ ] as const)('renders %s as a distinct state', (kind, title) => {
+ const wrapper = mount(RequestState, { props: { error: new BobbinError(kind, 'Test') } })
+
+ expect(wrapper.get('strong').text()).toBe(title)
+ })
+
+ test('keeps refresh failures compact when content is still present', () => {
+ const wrapper = mount(RequestState, { props: { error: new BobbinError('network', 'Test'), hasContent: true } })
+
+ expect(wrapper.classes()).toContain('request-state--inline')
+ expect(wrapper.get('strong').text()).toBe('Could not refresh')
+ })
+
+ test('disables retry for the Retry-After period', () => {
+ const wrapper = mount(RequestState, { props: { error: new BobbinError('rate-limited', 'Test'), retryInMs: 2_400 } })
+
+ expect(wrapper.get('button').attributes('disabled')).toBeDefined()
+ expect(wrapper.get('button').text()).toBe('Retry in 3s')
+ })
+})
+
+describe('CoverageNotice', () => {
+ test('explains incomplete coverage without replacing available results', () => {
+ const wrapper = mount({
+ components: { CoverageNotice },
+ template: `
+
+ `,
+ })
+
+ expect(wrapper.get('#results').text()).toBe('Indexed result')
+ expect(wrapper.text()).toContain('results may be incomplete')
+ })
+
+ test('stays hidden when indexing is ready', () => {
+ const wrapper = mount(CoverageNotice, { props: { coverage: { ready: true, eventsProcessed: 10, lastCursor: 20 } } })
+
+ expect(wrapper.html()).toBe('')
+ })
+})
diff --git a/tests/unit/requests/use-route-request.spec.ts b/tests/unit/requests/use-route-request.spec.ts
new file mode 100644
index 0000000..2b500f8
--- /dev/null
+++ b/tests/unit/requests/use-route-request.spec.ts
@@ -0,0 +1,75 @@
+import { BobbinError } from '@/api'
+import { useRouteRequest } from '@/requests/useRouteRequest'
+import { flushPromises } from '@vue/test-utils'
+import { effectScope, nextTick, ref } from 'vue'
+import { afterEach, describe, expect, test, vi } from 'vitest'
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('useRouteRequest', () => {
+ test('aborts obsolete route work and ignores its late result', async () => {
+ const source = ref('first')
+ const calls: Array<{ signal: AbortSignal; value: string; resolve: (result: string) => void }> = []
+ const scope = effectScope()
+ const state = scope.run(() =>
+ useRouteRequest(
+ source,
+ (value, signal) => new Promise((resolve) => calls.push({ signal, value, resolve })),
+ ),
+ )!
+
+ expect(calls[0].value).toBe('first')
+ source.value = 'second'
+ await nextTick()
+ expect(calls[0].signal.aborted).toBe(true)
+ expect(calls[1].value).toBe('second')
+
+ calls[0].resolve('obsolete')
+ calls[1].resolve('current')
+ await flushPromises()
+ expect(state.data.value).toBe('current')
+ expect(state.phase.value).toBe('ready')
+ scope.stop()
+ })
+
+ test('preserves successful content when a refresh fails', async () => {
+ const load = vi.fn().mockResolvedValueOnce(['existing']).mockRejectedValueOnce(new BobbinError('network', 'Failed'))
+ const scope = effectScope()
+ const state = scope.run(() => useRouteRequest(ref('route'), load, { isEmpty: (items) => items.length === 0 }))!
+ await flushPromises()
+
+ await state.refresh()
+ expect(state.data.value).toEqual(['existing'])
+ expect(state.hasContent.value).toBe(true)
+ expect(state.phase.value).toBe('error')
+ expect(state.error.value?.kind).toBe('network')
+ expect(load.mock.calls[0][2]).toEqual({ cache: 'default' })
+ expect(load.mock.calls[1][2]).toEqual({ cache: 'reload' })
+ scope.stop()
+ })
+
+ test('does not retry before Retry-After expires', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_000)
+ const load = vi
+ .fn()
+ .mockRejectedValueOnce(new BobbinError('rate-limited', 'Slow down', { retryAfterMs: 2_000 }))
+ .mockResolvedValueOnce('ready')
+ const scope = effectScope()
+ const state = scope.run(() => useRouteRequest(ref('route'), load))!
+ await flushPromises()
+
+ expect(state.retryInMs.value).toBe(2_000)
+ expect(state.retry()).toBe(false)
+ expect(load).toHaveBeenCalledOnce()
+
+ vi.advanceTimersByTime(2_000)
+ expect(state.retry()).toBe(true)
+ await flushPromises()
+ expect(load).toHaveBeenCalledTimes(2)
+ expect(state.data.value).toBe('ready')
+ scope.stop()
+ })
+})
diff --git a/tests/unit/router.spec.ts b/tests/unit/router.spec.ts
index 49c4534..36ad2e0 100644
--- a/tests/unit/router.spec.ts
+++ b/tests/unit/router.spec.ts
@@ -19,6 +19,36 @@ describe('application routes', () => {
expect(router.resolve(links.string('at://did:plc:abc/sh.tangled.feed.string/key')).name).toBe('string')
})
+ test('provides named builders for every opaque feature route', () => {
+ const actor = 'did:plc:abc'
+ const repo = 'at://did:plc:abc/sh.tangled.repo/key'
+ const locations = [
+ [links.actorActivity(actor, 'issues'), 'actor-activity'],
+ [links.actorRelationships(actor, 'followers'), 'actor-relationships'],
+ [links.repository(repo), 'repository'],
+ [links.commits(repo, 'refs/heads/main', 'src/main.ts'), 'repository-commits'],
+ [links.commit(repo, 'abc123'), 'repository-commit'],
+ [links.branches(repo), 'repository-branches'],
+ [links.tags(repo), 'repository-tags'],
+ [links.diff(repo, 'refs/heads/main'), 'repository-diff'],
+ [links.compare(repo, 'main', 'feature/theme'), 'repository-compare'],
+ [links.issues(repo, { state: 'open' }), 'issues'],
+ [links.issue(repo, '3k.test'), 'issue'],
+ [links.pulls(repo, { status: 'open' }), 'pulls'],
+ [links.pull(repo, '3k.pull'), 'pull'],
+ [links.pipelines(repo, 'pipeline/key'), 'pipelines'],
+ [links.artifact(repo, 'artifact/key'), 'artifact'],
+ [links.string('at://did:plc:abc/sh.tangled.feed.string/key'), 'string'],
+ [links.knot('knot.example'), 'knot'],
+ [links.spindle('spindle/key'), 'spindle'],
+ [links.labels('at://did:plc:abc/sh.tangled.label.scope/key'), 'labels'],
+ ] as const
+
+ for (const [location, name] of locations) {
+ expect(router.resolve(location).name).toBe(name)
+ }
+ })
+
test('sends unknown deep links to the recovery route', () => {
expect(router.resolve('/not/a/twisted/domain').name).toBe('not-found')
})
--
2.51.2