Initial Commit
This commit is contained in:
824
openspec/specs/packer-browser/spec.md
Normal file
824
openspec/specs/packer-browser/spec.md
Normal file
@@ -0,0 +1,824 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec: packer-browser
|
||||
|
||||
**Package**: `@suaveplan/packer-browser`
|
||||
**Status**: SHIPPED (Cluster A — `2026-05-19-add-packer-browser-core`)
|
||||
|
||||
> Every requirement carries a `**Verified by:**` line referencing the
|
||||
> concrete `it(...)` titles that pin it.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: PACKER-BROWSER-1 — Manager split into lifecycle / cache / loader
|
||||
|
||||
The package SHALL split the upstream 318-LOC `PackedResourceManager` into
|
||||
three collaborator modules under the 300-LOC hard cap:
|
||||
`src/manager/manager.ts` (lifecycle façade, event emission, public surface),
|
||||
`src/cache/cache.ts` (LRU + ref-count + Blob-URL bookkeeping), and
|
||||
`src/loader/loader.ts` (fetch + signature verification + Unpacker + Blob
|
||||
construction). `PackedResourceManager` MUST compose a `PackedResourceCache`
|
||||
and a `PackedResourceLoader`; neither collaborator is allowed to import the
|
||||
other. Each of the three files MUST be ≤ 300 lines per CLAUDE.md §3.
|
||||
|
||||
#### Scenario: each module is under the 300-line cap
|
||||
|
||||
- **WHEN** the package source tree is measured
|
||||
- **THEN** `src/manager/manager.ts`, `src/cache/cache.ts`, and
|
||||
`src/loader/loader.ts` are each ≤ 300 lines
|
||||
|
||||
#### Scenario: manager composes cache and loader
|
||||
|
||||
- **WHEN** `createPackedResourceManager()` is constructed with no options
|
||||
- **THEN** `manager.load(url)` delegates to the loader for IO and to the
|
||||
cache for cache-key resolution / insertion / eviction, and emits
|
||||
`cacheHit` / `cacheMiss` / `load` events accordingly
|
||||
|
||||
**Verified by:** `src/manager/manager.test.ts::"composes a cache and a loader and emits cacheHit on the second load"` AND `src/cache/cache.test.ts::"evicts the least-recently-used entry with refCount=0 when capacity is exceeded"` AND `src/loader/loader.test.ts::"fetches packed bytes and returns a Blob URL with the metadata-derived mime type"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-2 — Service Worker is unit-tested via a real shim
|
||||
|
||||
The package SHALL ship a hand-rolled `ServiceWorkerGlobalScope` shim
|
||||
(`createServiceWorkerShim`) at `src/service-worker/sw-shim.ts` that
|
||||
implements `addEventListener`, `dispatchEvent`, `skipWaiting`, `clients`,
|
||||
`caches` (a `CacheStorage` + `Cache` real implementation, not a stub),
|
||||
`FetchEvent`, `ExtendableEvent`, and `ExtendableMessageEvent`. The shim
|
||||
MUST be a working in-memory implementation — no `vi.mock`, no `vi.fn` for
|
||||
the shim itself, no stubs. The service-worker module
|
||||
`src/service-worker/service-worker.ts` MUST register its handlers against
|
||||
the shim in tests; the same module is loaded verbatim by the production SW
|
||||
bundle. Tests MUST exercise: install (skipWaiting called), activate (old
|
||||
cache deletion + clients.claim called), fetch (packed-URL interception +
|
||||
cache write), fetch (non-packed URL passthrough), message (CACHE_CLEAR +
|
||||
CACHE_STATUS round trip).
|
||||
|
||||
#### Scenario: install handler calls skipWaiting
|
||||
|
||||
- **WHEN** an `install` event is dispatched on the shim after
|
||||
`runPackedResourceServiceWorker(shim)` is called
|
||||
- **THEN** the shim records that `skipWaiting()` was invoked exactly once
|
||||
|
||||
#### Scenario: fetch handler intercepts a packed URL
|
||||
|
||||
- **WHEN** a `FetchEvent` for a URL matching `/\.packed$/` is dispatched
|
||||
and the loader returns unpacked bytes
|
||||
- **THEN** the event's `respondWith` is called with a `Response` carrying
|
||||
the unpacked bytes plus an `X-Unpacked: true` header, and a copy is
|
||||
written to the shim's `Cache`
|
||||
|
||||
#### Scenario: fetch handler passes through non-packed URLs
|
||||
|
||||
- **WHEN** a `FetchEvent` for a URL that does not match a packed pattern
|
||||
is dispatched
|
||||
- **THEN** `respondWith` is NOT called and the event propagates normally
|
||||
|
||||
**Verified by:** `src/service-worker/sw-shim.test.ts::"createServiceWorkerShim implements addEventListener, dispatchEvent, caches, and clients"` AND `src/service-worker/service-worker.test.ts::"install handler calls skipWaiting on the registered scope"` AND `src/service-worker/service-worker.test.ts::"fetch handler intercepts a packed URL and writes the unpacked response to the cache"` AND `src/service-worker/service-worker.test.ts::"fetch handler passes through URLs that do not match a packed pattern"` AND `src/service-worker/service-worker.test.ts::"message handler answers CACHE_CLEAR and CACHE_STATUS round trips"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-3 — Archive signature verification (Ed25519) before unpack
|
||||
|
||||
The package SHALL expose `verifyArchiveSignature(manifestBytes, signature,
|
||||
publicKey, opts?)` at `src/signature/signature.ts` which calls
|
||||
`@suaveplan/crypto`'s `ed25519.verify(publicKey, signature, manifestBytes)`
|
||||
and returns the boolean result. The loader MUST invoke
|
||||
`verifyArchiveSignature` before passing bytes to `Unpacker` when the caller
|
||||
supplies a `verifyKey` and the response carries an `X-Archive-Signature`
|
||||
header (hex-encoded 64-byte signature). A failed verification MUST throw
|
||||
`PackerBrowserError(PACKER_BROWSER.ARCHIVE_SIGNATURE_INVALID)` with the
|
||||
i18n key `packer-browser.error.ARCHIVE_SIGNATURE_INVALID.verify-failed`
|
||||
BEFORE any bytes are unpacked.
|
||||
|
||||
#### Scenario: valid signature passes through to unpack
|
||||
|
||||
- **WHEN** the loader is supplied a `verifyKey` and the response signature
|
||||
verifies against the manifest bytes
|
||||
- **THEN** the loader proceeds to `Unpacker.unpack` and returns the Blob URL
|
||||
|
||||
#### Scenario: invalid signature aborts before unpack
|
||||
|
||||
- **WHEN** the supplied signature does not verify
|
||||
- **THEN** the loader throws `PackerBrowserError` with code
|
||||
`PACKER_BROWSER.ARCHIVE_SIGNATURE_INVALID`, `Unpacker.unpack` is not
|
||||
called, and no Blob URL is created
|
||||
|
||||
#### Scenario: missing signature header is tolerated when verifyKey is absent
|
||||
|
||||
- **WHEN** the caller does not pass `verifyKey`
|
||||
- **THEN** the loader skips signature verification and proceeds
|
||||
|
||||
**Verified by:** `src/signature/signature.test.ts::"returns true when ed25519 verifies the signature"` AND `src/signature/signature.test.ts::"returns false when ed25519 rejects the signature"` AND `src/loader/loader.test.ts::"throws ARCHIVE_SIGNATURE_INVALID before calling Unpacker.unpack on a bad signature"` AND `src/loader/loader.test.ts::"skips signature verification when verifyKey is absent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-4 — OTel metrics from production paths
|
||||
|
||||
The package SHALL emit four OpenTelemetry instruments from production code
|
||||
paths via `createPackerBrowserObservability()`:
|
||||
`packed_resource_fetch_total` (Counter — incremented on every loader
|
||||
`fetch` attempt), `packed_resource_cache_hits_total` (Counter — incremented
|
||||
on every cache hit inside `PackedResourceCache.get`),
|
||||
`packed_resource_cache_miss_total` (Counter — incremented on every cache
|
||||
miss inside `PackedResourceCache.get`), and `packed_resource_decode_ms`
|
||||
(Histogram — observed once per successful loader decode with the elapsed
|
||||
ms between fetch start and Blob construction). The instruments MUST be
|
||||
asserted under a real in-memory `MeterProvider` from
|
||||
`@opentelemetry/sdk-metrics` — no `vi.mock` is permitted.
|
||||
|
||||
#### Scenario: cache hit increments the cache_hits counter
|
||||
|
||||
- **WHEN** `cache.get(key)` returns a hit on the second call with the same
|
||||
key
|
||||
- **THEN** the asserted `MeterProvider`'s collected metrics show
|
||||
`packed_resource_cache_hits_total >= 1`
|
||||
|
||||
#### Scenario: loader decode observes the histogram
|
||||
|
||||
- **WHEN** the loader completes a successful unpack
|
||||
- **THEN** the asserted `MeterProvider`'s collected metrics show
|
||||
`packed_resource_decode_ms` recorded at least one data point
|
||||
|
||||
**Verified by:** `src/observability/observability.test.ts::"createPackerBrowserObservability instantiates the four named instruments on the supplied Meter"` AND `src/cache/cache.test.ts::"increments packed_resource_cache_hits_total on a hit and packed_resource_cache_miss_total on a miss"` AND `src/loader/loader.test.ts::"records packed_resource_decode_ms on a successful decode"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-5 — Factory + `/singleton` subpath
|
||||
|
||||
The package SHALL expose `createPackedResourceManager(options?:
|
||||
PackedResourceManagerOptions)` from `src/manager/manager.ts` as the only
|
||||
construction surface in the default `.` export. The default export MUST
|
||||
NOT auto-construct a global instance at import time. The module-singleton
|
||||
`packedResourceManager` SHALL be hosted exclusively behind the
|
||||
`/singleton` subpath export (`src/singleton.ts`) so that consumers who
|
||||
import `@suaveplan/packer-browser` pay no import-time cost for the
|
||||
singleton.
|
||||
|
||||
#### Scenario: default export carries no auto-constructed singleton
|
||||
|
||||
- **WHEN** the default `@suaveplan/packer-browser` entry is loaded
|
||||
- **THEN** no `PackedResourceManager` instance exists in module scope
|
||||
(verified by introspecting the module's exported keys)
|
||||
|
||||
#### Scenario: /singleton subpath returns a stable shared instance
|
||||
|
||||
- **WHEN** `@suaveplan/packer-browser/singleton` is imported twice
|
||||
- **THEN** both imports return the same `packedResourceManager` reference
|
||||
|
||||
**Verified by:** `src/manager/manager.test.ts::"createPackedResourceManager returns a fresh instance per call"` AND `src/singleton.test.ts::"packedResourceManager is the same reference across imports"` AND `src/singleton.test.ts::"the default index module does not export a packedResourceManager singleton"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-6 — Error hierarchy via `@suaveplan/error`
|
||||
|
||||
The package SHALL define `PACKER_BROWSER_CODES` via `defineCodes("PACKER_BROWSER", [...])`
|
||||
from `@suaveplan/error` carrying at least six codes:
|
||||
`RESOURCE_NOT_FOUND`, `SW_REGISTRATION_FAILED`,
|
||||
`ARCHIVE_SIGNATURE_INVALID`, `UNSUPPORTED_RESOURCE_TYPE`, `ABORTED`,
|
||||
`LOAD_FAILED`. Every error thrown by the package MUST be a
|
||||
`PackerBrowserError` (extending `SuaveplanError`) carrying one of these
|
||||
codes. Each code MUST be reachable from at least one production call
|
||||
site (no unused registry entries — CLAUDE.md §2c).
|
||||
|
||||
#### Scenario: every registered code is thrown by a production path
|
||||
|
||||
- **WHEN** the production source tree is grepped for each
|
||||
`PACKER_BROWSER_CODES.<CODE>` symbol
|
||||
- **THEN** every code has at least one reference outside `error-codes.ts`,
|
||||
the i18n catalog, and test files
|
||||
|
||||
#### Scenario: thrown errors are PackerBrowserError instances
|
||||
|
||||
- **WHEN** the loader rejects on a 404 packed URL
|
||||
- **THEN** the rejection is `instanceof PackerBrowserError` and its
|
||||
`.code === PACKER_BROWSER.RESOURCE_NOT_FOUND`
|
||||
|
||||
**Verified by:** `src/error-codes.test.ts::"PACKER_BROWSER_CODES exposes the six stable codes"` AND `src/error-codes.test.ts::"every registered code is referenced by a production path"` AND `src/loader/loader.test.ts::"throws PackerBrowserError(RESOURCE_NOT_FOUND) on a 404 response"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-7 — `AbortSignal` propagation on every async entrypoint
|
||||
|
||||
Every async public entrypoint SHALL accept an optional `signal?:
|
||||
AbortSignal` and check it at every yield point. The following surfaces
|
||||
MUST honour `signal`: `PackedResourceManager.load`,
|
||||
`PackedResourceManager.preload`, `PackedResourceLoader.load`,
|
||||
`registerPackedResourceServiceWorker`, `clearServiceWorkerCache`,
|
||||
`getServiceWorkerCacheStatus`, the SW `fetch` event handler (composing
|
||||
caller's `event.request.signal` with an internal abort). A pre-aborted
|
||||
signal MUST cause the call to reject with
|
||||
`PackerBrowserError(PACKER_BROWSER.ABORTED)` BEFORE any IO runs.
|
||||
|
||||
#### Scenario: pre-aborted signal short-circuits manager.load
|
||||
|
||||
- **WHEN** `manager.load(url, { signal })` is called with `signal.aborted === true`
|
||||
- **THEN** the call rejects with `PackerBrowserError(PACKER_BROWSER.ABORTED)`
|
||||
and no `fetch` is issued
|
||||
|
||||
#### Scenario: signal fires after fetch but before unpack
|
||||
|
||||
- **WHEN** the loader has fetched bytes and the signal fires before
|
||||
`Unpacker.unpack` runs
|
||||
- **THEN** the call rejects with `PackerBrowserError(PACKER_BROWSER.ABORTED)`
|
||||
and no Blob URL is created
|
||||
|
||||
**Verified by:** `src/loader/loader.test.ts::"rejects with ABORTED when signal is pre-aborted before fetch"` AND `src/loader/loader.test.ts::"rejects with ABORTED when signal fires between fetch and unpack"` AND `src/manager/manager.test.ts::"propagates AbortSignal from manager.load through to the loader"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-8 — i18n MessageResolver keys, no hard-coded English
|
||||
|
||||
The package SHALL define a `PACKER_BROWSER_MESSAGES` catalog of
|
||||
`packer-browser.error.<CODE>.<scenario>` keys mapped to English defaults
|
||||
and a `defaultPackerBrowserResolver: MessageResolver` (from
|
||||
`@suaveplan/types`). Every thrown error message MUST be obtained from
|
||||
`resolvePackerBrowserMessage(resolver, key, params?)` — no hard-coded
|
||||
English string literals at `throw` sites.
|
||||
|
||||
#### Scenario: every key referenced from src has a catalog entry
|
||||
|
||||
- **WHEN** the test harness scans `src/**/*.ts` for
|
||||
`resolvePackerBrowserMessage(..., "<key>")` call sites
|
||||
- **THEN** every referenced key exists in `PACKER_BROWSER_MESSAGES`
|
||||
|
||||
#### Scenario: resolver interpolates `{name}` placeholders
|
||||
|
||||
- **WHEN** `defaultPackerBrowserResolver("packer-browser.error.RESOURCE_NOT_FOUND.http-status", { status: 404 })` is called
|
||||
- **THEN** the returned string contains `"404"`
|
||||
|
||||
**Verified by:** `src/i18n/messages.test.ts::"PACKER_BROWSER_MESSAGES covers every key referenced from src"` AND `src/i18n/messages.test.ts::"defaultPackerBrowserResolver interpolates named placeholders"` AND `src/i18n/messages.test.ts::"resolvePackerBrowserMessage falls back to the bare key on a missing entry"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-9 — Injected `Clock` / `Rng`, no direct `Date.now`/`Math.random`
|
||||
|
||||
The package SHALL accept `clock?: Clock` and `rng?: Rng` on every public
|
||||
option bundle that needs wall-clock or randomness, defaulting through
|
||||
`resolveRuntime(opts)` (mirrors `@suaveplan/packer/runtime`). Every
|
||||
production read of wall-clock time MUST go through the resolved `Clock`;
|
||||
no production file MAY call `Date.now()` or `Math.random()` directly.
|
||||
|
||||
#### Scenario: cache uses the injected Clock for entry timestamps
|
||||
|
||||
- **WHEN** `createPackedResourceManager({ clock: () => 1_700_000_000_000 })`
|
||||
is constructed and a resource is loaded
|
||||
- **THEN** the cached entry's `cachedAt` is `1_700_000_000_000`
|
||||
|
||||
#### Scenario: no production file calls Date.now / Math.random directly
|
||||
|
||||
- **WHEN** a recursive grep against `src/**/*.ts` (excluding `*.test.ts`,
|
||||
`*.types.ts`) is run for `Date\.now\|Math\.random`
|
||||
- **THEN** zero matches exist outside `src/runtime/runtime.ts`
|
||||
|
||||
**Verified by:** `src/runtime/runtime.test.ts::"resolveRuntime threads through caller-supplied Clock and Rng"` AND `src/cache/cache.test.ts::"entry timestamps come from the injected Clock"` AND `src/runtime/runtime.test.ts::"no production file calls Date.now or Math.random outside runtime defaults"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-10 — `@suaveplan/retry` on the IO source-pull path
|
||||
|
||||
The loader's `fetch` step SHALL route through `@suaveplan/retry`'s
|
||||
`withRetry`. The retry policy MUST be configurable per call via
|
||||
`PackedResourceLoaderOptions.retryConfig?: RetryConfig`. When `retryConfig`
|
||||
is absent the loader MUST default to a single-attempt policy
|
||||
(`maxAttempts: 1`) so wire behaviour matches the direct-fetch upstream
|
||||
contract. The retry loop MUST be `AbortSignal`-aware: cancellation aborts
|
||||
the loop immediately and surfaces as the retry package's
|
||||
`RetryAbortedError`.
|
||||
|
||||
#### Scenario: failing fetch is retried up to maxAttempts
|
||||
|
||||
- **WHEN** the loader is configured with
|
||||
`retryConfig: { maxAttempts: 3, delay: createConstantBackoff({ delay: 0 }) }`
|
||||
and the injected `fetch` rejects twice then resolves
|
||||
- **THEN** the loader returns successfully and the injected `fetch` was
|
||||
called three times
|
||||
|
||||
#### Scenario: default is a single attempt with no retries
|
||||
|
||||
- **WHEN** `retryConfig` is absent and `fetch` rejects once
|
||||
- **THEN** the rejection surfaces unchanged on the first attempt
|
||||
|
||||
**Verified by:** `src/loader/loader.test.ts::"retries fetch up to maxAttempts when retryConfig is supplied"` AND `src/loader/loader.test.ts::"defaults to a single fetch attempt when retryConfig is absent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-11 — `@suaveplan/feature-flags` provider injected
|
||||
|
||||
The package SHALL accept an optional `flags?: FlagProvider` (type from
|
||||
`@suaveplan/feature-flags`) on `PackedResourceManagerOptions`. When
|
||||
absent, a no-op `MemoryProvider` MUST be used. The provider slot exists
|
||||
to gate Cluster B / C features; Cluster A registers no live flags but
|
||||
MUST expose the slot so adopters can configure it without an API change
|
||||
later.
|
||||
|
||||
#### Scenario: default uses a no-op MemoryProvider
|
||||
|
||||
- **WHEN** `createPackedResourceManager()` is constructed with no `flags`
|
||||
- **THEN** the manager's resolved flag provider is a `MemoryProvider`
|
||||
carrying no flag definitions
|
||||
|
||||
#### Scenario: caller-supplied provider is preserved
|
||||
|
||||
- **WHEN** `createPackedResourceManager({ flags: customProvider })` is
|
||||
constructed
|
||||
- **THEN** `manager.getFlagProvider()` returns the same `customProvider`
|
||||
reference
|
||||
|
||||
**Verified by:** `src/flags/flags.test.ts::"resolveFlagsProvider returns a MemoryProvider when no provider is supplied"` AND `src/flags/flags.test.ts::"resolveFlagsProvider returns the caller-supplied provider unchanged"` AND `src/manager/manager.test.ts::"manager.getFlagProvider returns the configured provider"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-12 — Subpath exports for the §3 minimum five
|
||||
|
||||
The package SHALL ship five subpath exports (plus `./package.json`):
|
||||
`.`, `./manager`, `./service-worker`, `./utils`, `./singleton`. Each
|
||||
subpath MUST be tree-shakeable (`sideEffects: false`) and MUST resolve to
|
||||
a dedicated entrypoint in `dist/`. The default `.` re-exports the public
|
||||
surface; the subpaths expose surgical imports per the audit's §3.10 ask.
|
||||
|
||||
#### Scenario: every subpath resolves to a built artefact
|
||||
|
||||
- **WHEN** the build runs (`vite build`)
|
||||
- **THEN** `dist/index.js`, `dist/singleton.js`, `dist/manager/index.js`,
|
||||
`dist/service-worker/index.js`, and `dist/utils/index.js` all exist
|
||||
|
||||
#### Scenario: package.json sideEffects is false
|
||||
|
||||
- **WHEN** `package.json` is inspected
|
||||
- **THEN** `sideEffects === false`
|
||||
|
||||
**Verified by:** `src/index.test.ts::"every subpath export resolves to a built dist artefact"` AND `src/index.test.ts::"package.json declares sideEffects: false"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-13 — Progressive streaming unpack
|
||||
|
||||
The package SHALL expose `PackedResourceLoader.loadStream(inputs, opts?)`
|
||||
and `PackedResourceManager.loadStream(inputs, opts?)` that return an
|
||||
`AsyncIterable<LoadedResource>` yielding each decoded packed entry as
|
||||
soon as its bytes are fully decoded — subsequent entries MUST NOT block
|
||||
already-decoded consumers. The streaming API MUST honour an injected
|
||||
`AbortSignal` at every yield point: a pre-aborted signal MUST cause the
|
||||
iterator to throw `PackerBrowserError(PACKER_BROWSER.ABORTED)` before
|
||||
any IO runs; a signal that fires mid-stream MUST terminate the iterator
|
||||
cleanly on the next yield. The streaming path MUST coexist with the
|
||||
existing batch `load` path — it is additive, not a replacement.
|
||||
|
||||
#### Scenario: each entry is emitted as soon as it's decoded
|
||||
|
||||
- **WHEN** `loader.loadStream([a, b, c])` is consumed and entry `b`
|
||||
resolves before `a`
|
||||
- **THEN** the iterator emits `b` first (order-of-completion), then `a`,
|
||||
then `c`
|
||||
|
||||
#### Scenario: pre-aborted signal short-circuits the stream
|
||||
|
||||
- **WHEN** `loader.loadStream(inputs, { signal })` is invoked with
|
||||
`signal.aborted === true`
|
||||
- **THEN** the iterator throws
|
||||
`PackerBrowserError(PACKER_BROWSER.ABORTED)` on the first `next()` and
|
||||
no `fetch` is issued
|
||||
|
||||
#### Scenario: mid-stream abort terminates cleanly
|
||||
|
||||
- **WHEN** the signal fires after the first entry is yielded but before
|
||||
the second
|
||||
- **THEN** the iterator throws `PackerBrowserError(PACKER_BROWSER.ABORTED)`
|
||||
on the next `next()` call and no further entries are decoded
|
||||
|
||||
#### Scenario: manager streams entries through to the cache
|
||||
|
||||
- **WHEN** `manager.loadStream(inputs)` yields a `LoadedResource`
|
||||
- **THEN** the resource is inserted into the cache before the iterator
|
||||
advances to the next entry
|
||||
|
||||
**Verified by:** `src/loader/loader-stream.test.ts::"emits each entry as soon as its bytes are decoded, in order of completion"` AND `src/loader/loader-stream.test.ts::"rejects with ABORTED when the signal is pre-aborted before any fetch"` AND `src/loader/loader-stream.test.ts::"terminates cleanly when the signal fires mid-stream"` AND `src/manager/manager.test.ts::"loadStream writes each decoded entry into the cache as it arrives"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-14 — Range-request Service Worker support
|
||||
|
||||
The package SHALL expose `serveRangeResponse(request, body, opts?)` at
|
||||
`src/range-response/range-response.ts` returning a `Response` that
|
||||
honours RFC 7233 §2.1 single-range semantics: a valid single
|
||||
`bytes=<start>-<end>` range whose body length meets the configured
|
||||
`rangeThreshold` MUST yield a `206 Partial Content` carrying
|
||||
`Content-Range: bytes <start>-<end>/<total>`, `Accept-Ranges: bytes`,
|
||||
and `Content-Length: <chunk-length>` headers. A multi-range request
|
||||
(e.g. `bytes=0-9,20-29`) MUST yield `416 Range Not Satisfiable`. A
|
||||
malformed range header MUST yield `416`. An `If-Range` header carrying
|
||||
an ETag MUST yield `206` only when the etag matches the body's etag;
|
||||
on mismatch the helper MUST yield `412 Precondition Failed`. The
|
||||
Service Worker `fetch` handler MUST invoke `serveRangeResponse` for
|
||||
packed-URL requests carrying a `Range` header when the cached body
|
||||
size exceeds `RunServiceWorkerOptions.rangeThreshold` (default `262144`
|
||||
bytes = 256 KiB); below the threshold the SW MUST return the full
|
||||
200 response as in Cluster A.
|
||||
|
||||
#### Scenario: valid single range above threshold yields 206
|
||||
|
||||
- **WHEN** `serveRangeResponse` is called with a request carrying
|
||||
`Range: bytes=0-9`, a 512 KiB body, and `rangeThreshold: 1024`
|
||||
- **THEN** the response is `206`, `Content-Range: bytes 0-9/524288`,
|
||||
`Accept-Ranges: bytes`, `Content-Length: 10`, body length 10
|
||||
|
||||
#### Scenario: body below threshold returns the full 200
|
||||
|
||||
- **WHEN** the body length is below `rangeThreshold`
|
||||
- **THEN** the response is `200` with the full body and
|
||||
`Accept-Ranges: bytes` still present
|
||||
|
||||
#### Scenario: multi-range request yields 416
|
||||
|
||||
- **WHEN** the request carries `Range: bytes=0-9,20-29`
|
||||
- **THEN** the response is `416 Range Not Satisfiable`
|
||||
|
||||
#### Scenario: If-Range etag mismatch yields 412
|
||||
|
||||
- **WHEN** the request carries `If-Range: "v2"` and the cached body's
|
||||
etag is `"v1"`
|
||||
- **THEN** the response is `412 Precondition Failed`
|
||||
|
||||
#### Scenario: SW fetch handler routes ranged requests through the helper
|
||||
|
||||
- **WHEN** a packed-URL fetch event carries a `Range` header and the
|
||||
cached body exceeds `rangeThreshold`
|
||||
- **THEN** the SW responds with `206`
|
||||
|
||||
**Verified by:** `src/range-response/range-response.test.ts::"yields 206 with Content-Range, Accept-Ranges, and Content-Length on a valid single range above threshold"` AND `src/range-response/range-response.test.ts::"returns the full 200 when the body is below rangeThreshold"` AND `src/range-response/range-response.test.ts::"yields 416 on a multi-range request"` AND `src/range-response/range-response.test.ts::"yields 412 on If-Range etag mismatch"` AND `src/range-response/range-response.test.ts::"yields 416 on a malformed Range header"` AND `src/service-worker/service-worker.test.ts::"fetch handler serves a 206 partial response for a ranged packed request above rangeThreshold"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-15 — Encrypted archives with pluggable keyring
|
||||
|
||||
The package SHALL define a `KeyringProvider` contract with
|
||||
`resolveKey(keyId: string, signal?: AbortSignal): Promise<Uint8Array>`
|
||||
(throwing `PackerBrowserError(PACKER_BROWSER.KEY_UNAVAILABLE)` when the
|
||||
keyId is not resolvable) and ship two implementations:
|
||||
`emptyKeyring()` (rejects every keyId — the default so unencrypted
|
||||
archives keep working) and `MemoryKeyring` (constructed from a
|
||||
`Record<string, Uint8Array>`). The encrypted archive wire format MUST
|
||||
carry the stable magic prefix `SP-ENC1\x01` followed by a 4-byte
|
||||
big-endian header length, a UTF-8 JSON header
|
||||
`{ "alg": "AES-256-GCM", "keyId": string, "nonce": <base64>, "aad"?: <base64> }`,
|
||||
and the AES-256-GCM ciphertext+tag. The loader MUST detect the magic
|
||||
prefix on every fetched body and, when a `keyring` is supplied,
|
||||
resolve the key, AES-GCM decrypt the ciphertext, and pass the
|
||||
resulting plaintext through the standard verify+unpack pipeline. A
|
||||
missing key MUST surface `KEY_UNAVAILABLE`; a tampered ciphertext
|
||||
(GCM tag mismatch) MUST surface `LOAD_FAILED`. The default unencrypted
|
||||
path MUST continue to work unchanged when no encryption prefix is
|
||||
present.
|
||||
|
||||
#### Scenario: empty keyring rejects every keyId
|
||||
|
||||
- **WHEN** `emptyKeyring().resolveKey("k1")` is awaited
|
||||
- **THEN** the promise rejects with
|
||||
`PackerBrowserError(PACKER_BROWSER.KEY_UNAVAILABLE)`
|
||||
|
||||
#### Scenario: memory keyring returns its registered keys
|
||||
|
||||
- **WHEN** `new MemoryKeyring({ k1: bytesA, k2: bytesB, k3: bytesC }).resolveKey("k2")` is awaited
|
||||
- **THEN** the promise resolves with `bytesB`
|
||||
|
||||
#### Scenario: loader decrypts an encrypted archive via the keyring
|
||||
|
||||
- **WHEN** the loader fetches a body carrying the `SP-ENC1` prefix and
|
||||
is configured with a keyring that resolves the header's `keyId`
|
||||
- **THEN** the loader AES-GCM decrypts, unpacks the plaintext, and
|
||||
returns a `LoadedResource` (no error surfaced)
|
||||
|
||||
#### Scenario: missing key surfaces KEY_UNAVAILABLE
|
||||
|
||||
- **WHEN** the loader fetches an encrypted body whose keyId is not in
|
||||
the configured keyring
|
||||
- **THEN** the loader throws
|
||||
`PackerBrowserError(PACKER_BROWSER.KEY_UNAVAILABLE)` and
|
||||
`Unpacker.unpack` is not called
|
||||
|
||||
#### Scenario: tampered ciphertext surfaces LOAD_FAILED
|
||||
|
||||
- **WHEN** the loader fetches an encrypted body whose ciphertext byte 0
|
||||
has been flipped
|
||||
- **THEN** the loader throws
|
||||
`PackerBrowserError(PACKER_BROWSER.LOAD_FAILED)` whose `cause` is
|
||||
the underlying AES-GCM tag-mismatch error
|
||||
|
||||
**Verified by:** `src/keyring/keyring.test.ts::"emptyKeyring rejects every keyId with KEY_UNAVAILABLE"` AND `src/keyring/keyring.test.ts::"MemoryKeyring resolves three fixture keys and rejects an unknown id with KEY_UNAVAILABLE"` AND `src/encrypted-archive/encrypted-archive.test.ts::"round-trips plaintext through AES-256-GCM with the SP-ENC1 wire format"` AND `src/encrypted-archive/encrypted-archive.test.ts::"rejects a tampered ciphertext with a GCM tag-mismatch error"` AND `src/loader/loader-encrypted.test.ts::"loader decrypts an SP-ENC1 archive via the injected keyring before unpacking"` AND `src/loader/loader-encrypted.test.ts::"loader throws KEY_UNAVAILABLE when the keyring cannot resolve the keyId"` AND `src/error-codes.test.ts::"KEY_UNAVAILABLE is referenced by a production path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-16 — Content-addressed versioned archive URLs
|
||||
|
||||
The package SHALL expose `computeArchiveContentHash(entries, formatVersion)`
|
||||
that returns a BLAKE3 Merkle root over canonical entry digests with the
|
||||
single-byte `formatVersion` appended to the seed material. The
|
||||
resulting hash MUST change when any entry changes OR when the
|
||||
formatVersion byte changes. The package SHALL expose
|
||||
`buildContentAddressedUrl(prefix, hash, ext?)` returning
|
||||
`<prefix>/<hash>.<ext>` (default `ext = "bundle.asset"` so the
|
||||
canonical URL is `/packed/<hash>.bundle.asset`). The Service Worker
|
||||
SHALL accept `RunServiceWorkerOptions.contentAddressed` (default `true`)
|
||||
and `RunServiceWorkerOptions.legacyUrlPattern` (optional `RegExp`); when
|
||||
`contentAddressed` is on, the SW's `activate` handler MUST invoke
|
||||
`cleanupOutdatedCaches(scope, prefix, keep)` which deletes every cache
|
||||
whose name starts with `prefix` and is not present in `keep` — so a new
|
||||
manifest hash invalidates old cache entries on next install. When
|
||||
`legacyUrlPattern` is supplied, the `fetch` handler MUST treat matching
|
||||
URLs as packed (parity with `/packed/<name>` from Cluster A) regardless
|
||||
of the content-addressed flag. The
|
||||
`PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED` flag MUST be registered
|
||||
against the manager's injected `FlagProvider` so adopters can flip the
|
||||
URL form at runtime without an API change.
|
||||
|
||||
#### Scenario: content hash is deterministic for the same entries
|
||||
|
||||
- **WHEN** `computeArchiveContentHash` is called twice with the same
|
||||
entries and the same formatVersion
|
||||
- **THEN** both calls return byte-identical 32-byte hashes
|
||||
|
||||
#### Scenario: content hash changes with formatVersion
|
||||
|
||||
- **WHEN** the same entries are hashed once with `formatVersion=1` and
|
||||
once with `formatVersion=2`
|
||||
- **THEN** the two hashes differ
|
||||
|
||||
#### Scenario: URL builder produces the canonical packed path
|
||||
|
||||
- **WHEN** `buildContentAddressedUrl("/packed", hashHex)` is called
|
||||
- **THEN** the result is `/packed/<hashHex>.bundle.asset`
|
||||
|
||||
#### Scenario: cleanupOutdatedCaches deletes outdated entries on activate
|
||||
|
||||
- **WHEN** the SW `activate` handler runs with `contentAddressed: true`
|
||||
and a current cache name list `keep = ["packed-resources-<newHash>"]`,
|
||||
and the scope already has `packed-resources-<oldHash>` cached
|
||||
- **THEN** `packed-resources-<oldHash>` is deleted and
|
||||
`packed-resources-<newHash>` is preserved
|
||||
|
||||
#### Scenario: legacy URL pattern still resolves when supplied
|
||||
|
||||
- **WHEN** the SW is configured with
|
||||
`legacyUrlPattern: /\/packed\/[a-z]+\.bundle\.asset$/` and a fetch
|
||||
event matching that pattern arrives
|
||||
- **THEN** the SW intercepts the request (treats it as packed)
|
||||
|
||||
**Verified by:** `src/content-address/content-address.test.ts::"computeArchiveContentHash is deterministic across calls with identical entries"` AND `src/content-address/content-address.test.ts::"computeArchiveContentHash differs when formatVersion changes"` AND `src/content-address/content-address.test.ts::"buildContentAddressedUrl emits the canonical /packed/<hash>.bundle.asset form"` AND `src/content-address/content-address.test.ts::"cleanupOutdatedCaches deletes caches not in the keep list and preserves caches in the keep list"` AND `src/service-worker/service-worker.test.ts::"activate handler invokes cleanupOutdatedCaches when contentAddressed is on"` AND `src/service-worker/service-worker.test.ts::"fetch handler treats legacyUrlPattern matches as packed when supplied"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-17 — Adaptive cache sizing from `navigator.storage.estimate()`
|
||||
|
||||
The package SHALL expose `createAdaptiveCacheSizer(opts)` at
|
||||
`src/adaptive-cache/adaptive-cache.ts`. The sizer SHALL, on construction
|
||||
and on a recurring interval (default 60_000 ms, driven by the injected
|
||||
`Clock`'s elapsed sampling), invoke an injected `storageEstimate` function
|
||||
(defaults to `() => navigator.storage.estimate()` when present) and call
|
||||
`cache.setMaxCacheSize(Math.max(1, Math.floor(quota * fraction)))` with the
|
||||
configured `fraction` (default `0.25`). The sizer MUST honour an injected
|
||||
`AbortSignal`: a pre-aborted signal causes the constructor to return a
|
||||
disposer without scheduling any work; aborting at any later point cancels
|
||||
the timer and rejects in-flight estimate awaits. When
|
||||
`navigator.storage.estimate` is unavailable AND no `storageEstimate` is
|
||||
injected, the sizer MUST fall back to the cache's static configured
|
||||
ceiling and emit no further updates. The
|
||||
`PackedResourceCache` SHALL expose `setMaxCacheSize(n: number): void` that
|
||||
updates the ceiling and runs `evictLRU()` until `size() <= n`. The
|
||||
manager SHALL accept `PackedResourceManagerOptions.adaptiveCache:
|
||||
AdaptiveCacheOptions` and SHALL register the
|
||||
`packer-browser.cache.adaptive` flag (default `true`) on the injected
|
||||
`FlagProvider`; when the flag is `false` the sizer MUST NOT be created.
|
||||
|
||||
#### Scenario: sizer applies the configured fraction of the estimated quota
|
||||
|
||||
- **WHEN** `createAdaptiveCacheSizer({ cache, fraction: 0.25, storageEstimate: async () => ({ quota: 4000, usage: 0 }) })` is invoked
|
||||
- **THEN** `cache.maxSize` is updated to `1000` after the initial estimate resolves
|
||||
|
||||
#### Scenario: missing estimate API falls back to the static ceiling
|
||||
|
||||
- **WHEN** the sizer is constructed with neither a `storageEstimate` injection nor a global `navigator.storage.estimate`
|
||||
- **THEN** the cache ceiling is unchanged and the sizer's `disposer()` is a safe no-op
|
||||
|
||||
#### Scenario: AbortSignal cancellation terminates the recurring updater
|
||||
|
||||
- **WHEN** the sizer is constructed with `signal: aborter.signal`, then `aborter.abort()` is called
|
||||
- **THEN** subsequent ticks of the recurring updater never invoke `storageEstimate` again
|
||||
|
||||
#### Scenario: setMaxCacheSize evicts excess entries
|
||||
|
||||
- **WHEN** the cache contains 5 unpinned entries and `cache.setMaxCacheSize(3)` is called
|
||||
- **THEN** the two least-recently-cached entries are evicted (revoked) and `cache.size()` is `3`
|
||||
|
||||
#### Scenario: flag-gated off skips construction
|
||||
|
||||
- **WHEN** the manager is constructed with `adaptiveCache: { enabled: false }`
|
||||
- **THEN** no sizer is created and `cache.maxSize` remains the static configured ceiling
|
||||
|
||||
**Verified by:** `src/adaptive-cache/adaptive-cache.test.ts::"applies the configured fraction of the estimated quota to the cache ceiling"` AND `src/adaptive-cache/adaptive-cache.test.ts::"falls back to the static ceiling when navigator.storage.estimate is unavailable"` AND `src/adaptive-cache/adaptive-cache.test.ts::"AbortSignal cancellation terminates the recurring updater"` AND `src/adaptive-cache/adaptive-cache.test.ts::"disposer returned from the sizer stops further estimate calls"` AND `src/cache/cache.test.ts::"setMaxCacheSize evicts unpinned entries until size meets the new ceiling"` AND `src/manager/manager.test.ts::"adaptiveCache.enabled=false skips sizer construction"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-18 — `<link rel="preload">` generation on SW install
|
||||
|
||||
The package SHALL expose `generatePreloadHints(manifest, opts?):
|
||||
readonly PreloadHintLink[]` at `src/preload/preload.ts` returning a pure
|
||||
list of `{ url, as, crossOrigin? }` records derived from the manifest's
|
||||
`preload: true` entries OR the top-N entries by `priority` (descending,
|
||||
ties broken by lexicographic url) when `opts.topN` is supplied. The
|
||||
package SHALL expose `injectPreloadHints(doc, hints): readonly
|
||||
HTMLLinkElement[]` which appends `<link rel="preload">` elements to
|
||||
`doc.head` when `doc` carries a `head`; in non-DOM contexts the helper
|
||||
MUST return an empty array without throwing. Every emitted hint MUST
|
||||
count toward `PackerBrowserCounters.fetchTotal` so dashboards account
|
||||
for preload traffic. The helpers MUST be re-exported from the
|
||||
new `/preload` subpath AND the existing root `.` entrypoint.
|
||||
|
||||
#### Scenario: hints derived from `preload: true` manifest entries
|
||||
|
||||
- **WHEN** `generatePreloadHints({ entries: [{ url: "/a", preload: true, as: "image" }, { url: "/b", preload: false, as: "font" }] })` is called
|
||||
- **THEN** the result contains exactly one hint with `{ url: "/a", as: "image" }`
|
||||
|
||||
#### Scenario: top-N by priority overrides the preload flag
|
||||
|
||||
- **WHEN** `generatePreloadHints({ entries: [{ url: "/a", priority: 1 }, { url: "/b", priority: 5 }, { url: "/c", priority: 3 }] }, { topN: 2 })` is called
|
||||
- **THEN** the result is `[{ url: "/b", ... }, { url: "/c", ... }]` in that order
|
||||
|
||||
#### Scenario: injectPreloadHints is feature-detected
|
||||
|
||||
- **WHEN** `injectPreloadHints(undefined, hints)` is invoked
|
||||
- **THEN** the call returns `[]` without throwing
|
||||
|
||||
#### Scenario: injection appends link elements to document head
|
||||
|
||||
- **WHEN** `injectPreloadHints(document, hints)` is invoked under happy-dom with two hints
|
||||
- **THEN** `document.head.querySelectorAll('link[rel="preload"]').length` is `2`
|
||||
|
||||
#### Scenario: each emitted hint increments fetchTotal
|
||||
|
||||
- **WHEN** `generatePreloadHints(manifest, { observability })` emits two hints
|
||||
- **THEN** the observability bundle's `fetchTotal` counter has been incremented twice
|
||||
|
||||
**Verified by:** `src/preload/preload.test.ts::"derives hints from manifest entries marked preload: true"` AND `src/preload/preload.test.ts::"top-N selection orders by descending priority"` AND `src/preload/preload.test.ts::"injectPreloadHints returns an empty array when the document is undefined"` AND `src/preload/preload.test.ts::"injectPreloadHints appends one link element per hint to document.head"` AND `src/preload/preload.test.ts::"emits one fetchTotal counter increment per generated hint when observability is supplied"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-19 — Multi-origin Service Worker helper
|
||||
|
||||
The package SHALL expose
|
||||
`registerCrossOriginPackedResourceServiceWorker(opts)` at
|
||||
`src/utils/cross-origin.ts` accepting `{ swUrl: string, scope: string,
|
||||
allowedOrigins: readonly string[], container?:
|
||||
ServiceWorkerContainerLike, messageResolver? }`. The helper SHALL parse
|
||||
`swUrl` and reject with
|
||||
`PackerBrowserError(PACKER_BROWSER.UNAUTHORIZED_ORIGIN)` BEFORE any
|
||||
`register()` call when the parsed origin is not present in
|
||||
`allowedOrigins`. On success the helper SHALL delegate to
|
||||
`registerPackedResourceServiceWorker` and then invoke
|
||||
`container.getRegistrations()` (mirroring `clients.matchAll({
|
||||
includeUncontrolled: true })` semantics by listing every registration
|
||||
regardless of activation state) so callers can verify the cross-origin
|
||||
SW is reachable. The new `PACKER_BROWSER.UNAUTHORIZED_ORIGIN` code SHALL
|
||||
be added to `PACKER_BROWSER_CODES` and reachable from this helper's
|
||||
production path.
|
||||
|
||||
#### Scenario: SW URL whose origin is not in the allowlist is rejected
|
||||
|
||||
- **WHEN** `registerCrossOriginPackedResourceServiceWorker({ swUrl: "https://attacker.com/sw.js", scope: "/", allowedOrigins: ["https://cdn.example.com"] })` is called
|
||||
- **THEN** the promise rejects with `PackerBrowserError(PACKER_BROWSER.UNAUTHORIZED_ORIGIN)`
|
||||
|
||||
#### Scenario: SW URL whose origin is in the allowlist registers
|
||||
|
||||
- **WHEN** the helper is called with `swUrl: "https://cdn.example.com/sw.js"` and `allowedOrigins: ["https://cdn.example.com"]`
|
||||
- **THEN** the underlying container's `register` is invoked exactly once with `("https://cdn.example.com/sw.js", { scope })`
|
||||
|
||||
#### Scenario: registration list is queried after success
|
||||
|
||||
- **WHEN** registration succeeds against the allowlisted origin
|
||||
- **THEN** the container's `getRegistrations()` is awaited and its returned array is exposed on the helper's resolved value
|
||||
|
||||
**Verified by:** `src/utils/cross-origin.test.ts::"rejects with UNAUTHORIZED_ORIGIN when swUrl origin is not allowlisted"` AND `src/utils/cross-origin.test.ts::"delegates to registerPackedResourceServiceWorker when origin is allowlisted"` AND `src/utils/cross-origin.test.ts::"returns the container's registration list after successful registration"` AND `src/error-codes.test.ts::"UNAUTHORIZED_ORIGIN is referenced by a production path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-20 — BackgroundFetch API integration
|
||||
|
||||
The package SHALL expose `prefetchPackedArchive(archiveUrl, opts):
|
||||
Promise<BackgroundFetchRegistrationLike | null>` at
|
||||
`src/prefetch/prefetch.ts`. When `opts.backgroundFetch` (or
|
||||
`navigator.serviceWorker.ready.backgroundFetch` resolved from a real
|
||||
`ServiceWorkerRegistration` in production) is available, the helper
|
||||
SHALL invoke `backgroundFetch.fetch(id, [archiveUrl], opts.options)` and
|
||||
resolve with the registration. When the API is missing, the helper
|
||||
SHALL resolve with `null`. The package SHALL expose
|
||||
`handleBackgroundFetchSuccess(event, deps)` for the SW's
|
||||
`backgroundfetchsuccess` listener: on success, the helper SHALL drain
|
||||
the event's records into the configured cache (default `packed-resources-v1`)
|
||||
and increment `PackerBrowserCounters.prefetchSuccessTotal`. On failure
|
||||
or `backgroundfetchfailure` invocations the helper SHALL increment
|
||||
`PackerBrowserCounters.prefetchFailureTotal`. The two new counters
|
||||
(`packed_resource_prefetch_success_total` and
|
||||
`packed_resource_prefetch_failure_total`) MUST be created on the
|
||||
observability bundle and asserted under a real in-memory
|
||||
`MeterProvider`.
|
||||
|
||||
#### Scenario: missing API returns null
|
||||
|
||||
- **WHEN** `prefetchPackedArchive("/x.packed", { backgroundFetch: undefined })` is called
|
||||
- **THEN** the promise resolves to `null` and no counters are incremented
|
||||
|
||||
#### Scenario: present API delegates to BackgroundFetchManager.fetch
|
||||
|
||||
- **WHEN** the helper is called with a shim `BackgroundFetchManager` whose `fetch` resolves
|
||||
- **THEN** the shim's `fetch` is invoked exactly once with the configured id, the archive URL array, and the options bundle, and the helper's resolved value is the shim's returned registration
|
||||
|
||||
#### Scenario: success handler drains records into the cache and increments the success counter
|
||||
|
||||
- **WHEN** `handleBackgroundFetchSuccess(event, { caches, cacheName: "test", observability })` is invoked against a shim event whose `matchAll` returns one record
|
||||
- **THEN** the configured cache contains the record's response and `prefetchSuccessTotal` is incremented exactly once
|
||||
|
||||
#### Scenario: failure handler increments the failure counter
|
||||
|
||||
- **WHEN** `handleBackgroundFetchFailure(event, { observability })` is invoked
|
||||
- **THEN** `prefetchFailureTotal` is incremented exactly once
|
||||
|
||||
**Verified by:** `src/prefetch/prefetch.test.ts::"resolves to null when BackgroundFetchManager is unavailable"` AND `src/prefetch/prefetch.test.ts::"delegates to BackgroundFetchManager.fetch when the API is available"` AND `src/prefetch/prefetch.test.ts::"success handler drains records into the cache and increments prefetchSuccessTotal"` AND `src/prefetch/prefetch.test.ts::"failure handler increments prefetchFailureTotal"` AND `src/observability/observability.test.ts::"prefetchSuccessTotal and prefetchFailureTotal are emitted under a real MeterProvider"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-21 — Diagnostics dev overlay
|
||||
|
||||
The package SHALL expose `createDiagnosticsOverlay(manager, opts?): {
|
||||
mount(parent?: ParentNode): () => void }` at
|
||||
`src/diagnostics/diagnostics.ts`. The factory SHALL subscribe to the
|
||||
manager's `TinyEmitter` events (`load`, `error`, `cacheHit`, `cacheMiss`,
|
||||
`cacheEvict`), maintain an in-memory ring of the last 50 events, and
|
||||
render a single `<div>` whose child nodes report cache size, hit-ratio,
|
||||
miss-ratio, in-flight count, and the event ring. The factory MUST NOT
|
||||
mutate the manager (subscribe-only). The mount method MUST return a
|
||||
disposer that removes the rendered element and unsubscribes every
|
||||
listener. When `document` is `undefined`, `mount` MUST return a no-op
|
||||
disposer without throwing.
|
||||
|
||||
#### Scenario: overlay renders cache stats from the manager
|
||||
|
||||
- **WHEN** the manager's cache contains two entries and the overlay is mounted onto a happy-dom `<body>`
|
||||
- **THEN** the rendered element's text contains the substring `cache.size=2`
|
||||
|
||||
#### Scenario: event ring records the last 50 events
|
||||
|
||||
- **WHEN** the manager emits 60 `cacheHit` events
|
||||
- **THEN** the overlay's event-ring snapshot contains exactly 50 entries, all of type `cacheHit`
|
||||
|
||||
#### Scenario: dispose removes the element and unsubscribes
|
||||
|
||||
- **WHEN** `mount` returns a disposer and the disposer is invoked
|
||||
- **THEN** the overlay's element is no longer attached to `parent`, and subsequent manager `emit` calls leave the snapshot unchanged
|
||||
|
||||
#### Scenario: no-document environment returns a safe disposer
|
||||
|
||||
- **WHEN** `mount` is called against an overlay constructed without `document`
|
||||
- **THEN** the disposer is a callable no-op and no errors are thrown
|
||||
|
||||
#### Scenario: overlay does not mutate the manager
|
||||
|
||||
- **WHEN** the overlay is mounted and `manager.getCacheStats()` is recorded before and after `mount` + `dispose`
|
||||
- **THEN** both snapshots are structurally equal
|
||||
|
||||
**Verified by:** `src/diagnostics/diagnostics.test.ts::"renders cache.size derived from the manager"` AND `src/diagnostics/diagnostics.test.ts::"event ring retains only the last 50 events"` AND `src/diagnostics/diagnostics.test.ts::"disposer removes the element and unsubscribes every listener"` AND `src/diagnostics/diagnostics.test.ts::"returns a safe no-op disposer when document is absent"` AND `src/diagnostics/diagnostics.test.ts::"mount and dispose do not mutate the manager's cache state"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-22 — Final subpath split + Playwright E2E coverage
|
||||
|
||||
The package SHALL ship three additional subpath exports on top of
|
||||
PACKER-BROWSER-12's five: `./preload`, `./prefetch`, `./diagnostics`.
|
||||
Each new subpath MUST be tree-shakeable
|
||||
(`"sideEffects": false`) and MUST resolve to a built artefact under
|
||||
`dist/`. The package SHALL ship Playwright spec files under `tests/e2e/`
|
||||
covering the following scenarios: SW install + packed URL intercept,
|
||||
range-request 206 response, encrypted archive end-to-end, content-
|
||||
addressed URL invalidation on SW activate, preload-hint DOM injection,
|
||||
and diagnostics overlay mount/unmount. The Playwright config SHALL
|
||||
validate without browser binaries installed; running the suite is gated
|
||||
on `bunx playwright install chromium` being executed in the runtime
|
||||
environment.
|
||||
|
||||
#### Scenario: every new subpath resolves to a built dist artefact
|
||||
|
||||
- **WHEN** the build runs and `package.json.exports` is inspected
|
||||
- **THEN** `./preload`, `./prefetch`, `./diagnostics` are present and each `import.default` value points at `./dist/<feature>/index.js`
|
||||
|
||||
#### Scenario: subpaths preserve tree-shake invariant
|
||||
|
||||
- **WHEN** `package.json` is inspected
|
||||
- **THEN** `sideEffects === false`
|
||||
|
||||
#### Scenario: Playwright config is valid and the E2E spec files exist
|
||||
|
||||
- **WHEN** the test harness scans `tests/e2e/`
|
||||
- **THEN** files exist for SW intercept, range 206, encrypted archive, content-addressed invalidation, preload injection, and diagnostics mount/unmount
|
||||
|
||||
**Verified by:** `src/index.test.ts::"every subpath export resolves to a built dist artefact (cluster C)"` AND `src/index.test.ts::"package.json declares sideEffects: false (cluster C)"` AND `src/index.test.ts::"every Playwright E2E spec file exists under tests/e2e/"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user