Files
orca/mobile/src/mobile-web-shell/generation-store.test.ts
T
OrcaWinandOrcaWin 7d6603aa17 [comment audit] Complete repository-wide comment cleanup (#33249)
* [comment audit] Align legacy failure-read counts with cached display

* [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci]

* [comment audit] Condense shared generation, path and preview comments [skip ci]

* [comment audit] Condense host, file and Git contract rationale [skip ci]

* [comment audit] Tighten Git lifecycle and provider contract comments [skip ci]

* [comment audit] Clarify shared host authority and launch isolation [skip ci]

* [comment audit] simplify local access mobile and native chat contracts [skip ci]

* [comment audit] simplify native chat presentation and state contracts [skip ci]

* [comment audit] simplify native runtime and provider contracts [skip ci]

* [comment audit] simplify migration and orchestration contracts [skip ci]

* [comment audit] simplify plugin trust and process capture rationale [skip ci]

* [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci]

* [comment audit] clarify remote recovery and launch wire rationale [skip ci]

* [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci]

* [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci]

* [comment audit] clarify sidebar, skill and SSH invariants [skip ci]

* [comment audit] condense structured chat delivery and timing rationale [skip ci]

* [comment audit] condense terminal replay and telemetry invariants [skip ci]

* [comment audit] clarify launch permission and terminal delivery rationale [skip ci]

* [comment audit] clarify workspace ownership and layout contracts [skip ci]

* [comment audit] wait for fitted and painted SSH terminal links

* [comment audit] clarify layout persistence and session ownership [skip ci]

* [comment audit] clarify host routing and workspace lifecycle contracts [skip ci]

* [comment audit] clarify startup persistence and app lifetime comments [skip ci]

* [comment audit] clarify recovery and activity boundaries [skip ci]

* [comment audit] condense activity and artifact invariants [skip ci]

* [comment audit] condense automation authority contracts [skip ci]

* [comment audit] simplify automation scheduling and history contracts [skip ci]

* [comment audit] clarify browser chrome and annotation lifecycles [skip ci]

* [comment audit] clarify browser routing and retained guest ownership [skip ci]

* [comment audit] clarify browser recovery and document ownership comments [skip ci]

* [comment audit] restore hosted runners required by native IME fixtures

* [comment audit] shorten preview, palette and dashboard invariants [skip ci]

* [comment audit] clarify terminal preview, dashboard and retention comments [skip ci]

* [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci]

* [comment audit] shorten rich-text and combined-diff ownership comments [skip ci]

* [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci]

* [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci]

* [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci]

* [comment audit] clarify rich Markdown source and selection safeguards [skip ci]

* [comment audit] clarify editor loading and emulator stream lifetimes [skip ci]

* [comment audit] trim emulator and feature tour narration [skip ci]

* [comment audit] clarify floating workspace and GitHub cache ownership [skip ci]

* [comment audit] trim provider controls and Linear filter explanations [skip ci]

* [comment audit] clarify pairing and chat resume lifetimes [skip ci]

* [comment audit] clarify chat delivery and transcript lifetimes [skip ci]

* [comment audit] clarify session options and host-owned status [skip ci]

* [comment audit] clarify draft durability and live-status precedence [skip ci]

* [comment audit] Trim chat send and model ownership explanations [skip ci]

* [comment audit] Shorten transcript ownership and send recovery comments [skip ci]

* [comment audit] Simplify native chat composer and transcript hook comments [skip ci]

* [comment audit] Shorten native chat scroll and structured session explanations [skip ci]

* [comment audit] Trim workspace picker and onboarding commentary [skip ci]

* [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci]

* [comment audit] Trim sidebar and session history explanations [skip ci]

* [comment audit] Condense session resume and review checks comments [skip ci]

* [comment audit] Shorten checks state and file explorer refresh comments [skip ci]

* [comment audit] Trim plugin bridge and source-control action rationale [skip ci]

* [comment audit] Trim source-control listing and review rationale [skip ci]

* [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci]

* [comment audit] Condense Settings account and appearance rationale [skip ci]

* [comment audit] Trim pairing and repository Settings explanations [skip ci]

* [comment audit] Condense Settings search SSH and terminal safeguards [skip ci]

* [comment audit] Shorten Settings voice and provider ownership comments [skip ci]

* [comment audit] Condense Settings draft persistence and host-state rationale [skip ci]

* [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci]

* [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci]

* [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci]

* [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci]

* [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci]

* [comment audit] Prune focus-read baseline after comment cleanup

* [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci]

* [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci]

* [comment audit] Clarify viewport geometry and skill update safeguards [skip ci]

* [comment audit] Tighten skill inventory, usage and host status comments [skip ci]

* [comment audit] Clarify usage ownership and resource kill safeguards [skip ci]

* [comment audit] Tighten tab selection, rename and search contracts [skip ci]

* [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci]

* [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci]

* [comment audit] Tighten provider loading and terminal recovery comments [skip ci]

* [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci]

* [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci]

* [comment audit] Tighten PTY startup and replay explanations [skip ci]

* [comment audit] Clarify PTY identity and delivery ownership [skip ci]

* [comment audit] Shorten remote reconnect and input replay rationale [skip ci]

* [comment audit] Condense terminal parking clipboard and file link comments [skip ci]

* [comment audit] Clarify IME ownership and trim trace history [skip ci]

* [comment audit] Shorten pane recovery and parked watcher explanations [skip ci]

* [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci]

* [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci]

* [comment audit] Trim renderer lifecycle and menu rationale [skip ci]

* [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci]

* [comment audit] Clarify renderer hook evidence and source ownership [skip ci]

* [comment audit] Tighten IPC ownership and reconnect explanations [skip ci]

* [comment audit] Trim renderer focus, paste and attention comments [skip ci]

* [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci]

* [comment audit] Trim agent launch, resume and ownership commentary [skip ci]

* [comment audit] Shorten account, browser and editor comments [skip ci]

* [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci]

* [comment audit] Condense launch recovery and editor tokenization comments [skip ci]

* [comment audit] Condense workspace selection and terminal pane rationale [skip ci]

* [comment audit] Condense terminal fitting and rendering recovery comments [skip ci]

* [comment audit] Condense terminal input and host routing explanations [skip ci]

* [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci]

* [comment audit] Condense structured launch and terminal link explanations [skip ci]

* [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci]

* [comment audit] Condense session persistence and workspace activation rationale [skip ci]

* [comment audit] Condense workspace routing and browser ownership rationale [skip ci]

* [comment audit] Condense mirror recovery and layout authority rationale [skip ci]

* [comment audit] Condense runtime transport and file safety rationale [skip ci]

* [comment audit] Condense runtime graph and structured session rationale [skip ci]

* [comment audit] Condense browser creation and remote tab recovery rationale [skip ci]

* [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci]

* [comment audit] Condense frame, startup and provider cache rationale [skip ci]

* [comment audit] Condense host twin and agent lifecycle rationale [skip ci]

* [comment audit] Condense browser authority and editor cleanup rationale [skip ci]

* [comment audit] Condense editor buffer identity and review cache rationale [skip ci]

* [comment audit] Condense provider lifetime and repository ownership rationale [skip ci]

* [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci]

* [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci]

* [comment audit] Condense cleanup ownership and listing admission rationale [skip ci]

* [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci]

* [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci]

* [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci]

* [comment audit] Condense cloud rollout and assignment safety rationale [skip ci]

* [comment audit] Condense relay session and database recovery rationale [skip ci]

* [comment audit] Condense relay placement and protocol compatibility rationale [skip ci]

* [comment audit] Condense build and packaging constraints [skip ci]

* [comment audit] Condense CI gates and validation isolation rationale [skip ci]

* [comment audit] Condense catalog identity and browser validation rationale [skip ci]

* [comment audit] Condense mobile browser and frame-budget rationale [skip ci]

* [comment audit] Shorten mobile route and browser test explanations [skip ci]

* [comment audit] Condense editor and session seam test rationale [skip ci]

* [comment audit] Trim session and terminal closure history [skip ci]

* [comment audit] Shorten terminal browser and census explanations [skip ci]

* [comment audit] Preserve root-only E2E diagnostic baseline

* [comment audit] Condense mobile seam and HTML preview test rationale [skip ci]

* [comment audit] Condense preview instrumentation and render harness rationale [skip ci]

* [comment audit] Condense bundle contracts and native-build constraints [skip ci]

* [comment audit] Shorten native packaging and CI routing rationale [skip ci]

* [comment audit] Condense release tooling and benchmark constraints [skip ci]

* [comment audit] Condense packaging and platform gate rationale [skip ci]

* [comment audit] Shorten mobile route and history rationale [skip ci]

* [comment audit] Condense mobile browser and component rationale [skip ci]

* [comment audit] Shorten drawer and workspace-form rationale [skip ci]

* [comment audit] Trim mobile review and editor-document explanations [skip ci]

* [comment audit] Condense editor lifecycle and Markdown explanations [skip ci]

* [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci]

* [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci]

* [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci]

* [comment audit] Condense bridge stream, native request and audio rationale [skip ci]

* [comment audit] Reduce bridge schema and page-client commentary [skip ci]

* [comment audit] Tighten mobile page bridge contracts [skip ci]

* [comment audit] Clarify mobile cache and session constraints [skip ci]

* [comment audit] Condense mobile route and lifecycle rationale [skip ci]

* [comment audit] Tighten mobile shell hook timing rationale [skip ci]

* [comment audit] Clarify route handoff and notification compatibility [skip ci]

* [comment audit] Clarify push registration and dictation cleanup constraints [skip ci]

* [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci]

* [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci]

* [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci]

* [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci]

* [comment audit] Tighten stream replay and queued session ownership rationale [skip ci]

* [comment audit] Condense mobile composer and reply-schema constraints [skip ci]

* [comment audit] Clarify mobile transcript recovery and session ownership [skip ci]

* [comment audit] Clarify mobile queue and source-control compatibility [skip ci]

* [comment audit] Condense storage and task launch constraints [skip ci]

* [comment audit] Clarify task schemas and terminal document ownership [skip ci]

* [comment audit] Tighten terminal input and viewport rationale [skip ci]

* [comment audit] Shorten terminal and RPC recorder contracts [skip ci]

* [comment audit] Condense recorder engine and connection contracts [skip ci]

* [comment audit] Shorten host credential and relay recovery comments [skip ci]

* [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci]

* [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci]

* [comment audit] Condense catalog and relay entry contracts [skip ci]

* [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci]

* [comment audit] Condense WSL relay and E2E regression rationale [skip ci]

* [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci]

* [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci]

* [comment audit] Shorten layout oracle and regression explanations [skip ci]

* [comment audit] Condense paired recovery and ownership regression contracts [skip ci]

* [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci]

* [comment audit] Condense terminal rendering and IME regression rationale [skip ci]

* [comment audit] Shorten terminal recovery and benchmark contracts [skip ci]

* [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci]

* [comment audit] Condense workflow routing and release rationale [skip ci]

* [comment audit] Condense mobile security and installer contracts [skip ci]

* [comment audit] Shorten native rationale and stylesheet workarounds [skip ci]

* [comment audit] Shorten Windows visibility probe rationale [skip ci]

* [comment audit] Condense embedded release and signing rationale [skip ci]

* [comment audit] Retain current main test behavior in final reconciliation [skip ci]

* [comment audit] Complete final comment review across current source inventory

* [comment audit] Handle consolidated audit reports before final CI

* [comment audit] Preserve credential storage census detail [skip ci]

* [comment audit] Scale broad changed E2E selections with verified timing shards

* [comment audit] Align E2E assertions with browser close and settled session metadata

* [comment audit] Match existing E2Es to current resource and windowing contracts

* [comment audit] Observe the shared server outage notice in reconnect coverage

* [comment audit] Make existing renderer probes measure settled tab and PTY work

* [comment audit] Align palette E2E selector with task search

* [comment audit] Refresh remaining palette search selectors

* [comment audit] Keep selector correction within selected audit specs

* [comment audit] Await emoji fixture settings inside renderer evaluation

* [comment audit] Cancel fixture fit retries between terminal tests

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-25 04:05:46 -07:00

656 lines
25 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { createGenerationStore, MAX_CACHED_HOSTS } from './generation-store'
import { deriveHostCacheKey } from './host-cache-key'
import type { MobileWebBundleFetchResult } from '../transport/mobile-web-bundle-fetch'
import { computeMobileWebBundleId } from '../../../src/shared/mobile-web-bundle/manifest-contract'
import {
createFakeGenerationFileSystem as createFakeFileSystem,
FAKE_GENERATION_ROOT,
type FakeGenerationFileSystem as FakeFileSystem
} from './generation-file-system-fake'
const ROOT = FAKE_GENERATION_ROOT
const HOST = deriveHostCacheKey('host-a')
function buildResult(options: {
buildId?: string
assets?: readonly { path: string; byteLength: number }[]
bytes?: ReadonlyMap<string, Uint8Array>
}): MobileWebBundleFetchResult {
const listed = options.assets ?? [
{ path: 'index.html', byteLength: 4 },
{ path: 'assets/app.js', byteLength: 2 }
]
const assets = listed.map((asset, index) => ({
path: asset.path,
sha256: String(index).repeat(64).slice(0, 64),
byteLength: asset.byteLength,
contentType: 'text/html; charset=utf-8'
}))
const totalBytes = assets.reduce((sum, asset) => sum + asset.byteLength, 0)
return {
manifest: {
schemaVersion: 1,
buildId: options.buildId ?? computeMobileWebBundleId(assets),
minCompatibleRuntimeProtocolVersion: 2,
runtimeProtocolVersion: 2,
entrypoint: 'index.html',
totalBytes,
assets
},
assets:
options.bytes ??
new Map(assets.map((asset) => [asset.path, new Uint8Array(asset.byteLength).fill(7)])),
reused: [],
totalBytes,
elapsedMs: 1
}
}
// Derive ids from assets so the cached manifest passes its digest check.
const BUILD = buildResult({}).manifest.buildId
const SECOND = { assets: [{ path: 'index.html', byteLength: 8 }] }
const SECOND_BUILD = buildResult(SECOND).manifest.buildId
async function activate(
store: ReturnType<typeof createGenerationStore>,
hostKey: string,
result = buildResult({})
): Promise<void> {
await store.commitGeneration(await store.stageGeneration(hostKey, result))
}
function hostKeys(count: number): string[] {
return Array.from({ length: count }, (_, index) => deriveHostCacheKey(`host-${index}`))
}
describe('generation store', () => {
it('stages and commits exactly the manifest, with the manifest written last', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
const build = BUILD
expect(fs.paths()).toEqual([
HOST,
`${HOST}/generations`,
`${HOST}/generations/${build}`,
`${HOST}/generations/${build}/assets`,
`${HOST}/generations/${build}/assets/app.js`,
`${HOST}/generations/${build}/index.html`,
`${HOST}/generations/${build}/manifest.json`,
`${HOST}/tmp`,
'hosts.json'
])
const staged = fs.writes.filter((path) => path.includes('/tmp/'))
expect(staged.at(-1)).toBe(`${HOST}/tmp/${build}/manifest.json`)
expect(staged).toHaveLength(3)
expect(fs.text('hosts.json')).toBe(JSON.stringify([HOST]))
})
it('reads back the activation it committed', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
const active = await store.readActiveGeneration(HOST)
expect(active?.buildId).toBe(BUILD)
expect(active?.directory).toBe(`${ROOT}/${HOST}/generations/${BUILD}`)
expect(active?.manifest.entrypoint).toBe('index.html')
expect(await store.readActiveGeneration(deriveHostCacheKey('never-opened'))).toBeNull()
})
it('refuses an asset that is missing or the wrong length, leaving no generation', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const missing = buildResult({ bytes: new Map([['index.html', new Uint8Array(4)]]) })
const short = buildResult({
bytes: new Map([
['index.html', new Uint8Array(4)],
['assets/app.js', new Uint8Array(1)]
])
})
await expect(store.stageGeneration(HOST, missing)).rejects.toThrow('assets/app.js is absent')
await expect(store.stageGeneration(HOST, short)).rejects.toThrow("not the manifest's 2")
expect(fs.paths()).toEqual([])
expect(await store.readActiveGeneration(HOST)).toBeNull()
})
it('drops the staged tree when a write fails', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
fs.failWritesAt(`${HOST}/tmp/${BUILD}/assets/app.js`)
await expect(store.stageGeneration(HOST, buildResult({}))).rejects.toThrow('disk-full')
expect(fs.paths().some((path) => path.includes(`tmp/${BUILD}`))).toBe(false)
expect(await store.readActiveGeneration(HOST)).toBeNull()
})
it('leaves no generation and no tmp for any host when a download is interrupted', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const other = deriveHostCacheKey('host-b')
await store.stageGeneration(HOST, buildResult({}))
await store.stageGeneration(other, buildResult({}))
await store.sweepStagedGenerations()
expect(fs.paths().some((path) => path.includes('/tmp'))).toBe(false)
expect(await store.readActiveGeneration(HOST)).toBeNull()
expect(await store.readActiveGeneration(other)).toBeNull()
})
it('treats a second commit of the same build as a no-op', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
const before = fs.paths()
const staged = await store.stageGeneration(HOST, buildResult({}))
const active = await store.commitGeneration(staged)
expect(active.buildId).toBe(BUILD)
expect(fs.paths()).toEqual(before)
})
it('replaces the previous generation when the build id changes', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
await activate(store, HOST, buildResult(SECOND))
expect(fs.paths().some((path) => path.includes(BUILD))).toBe(false)
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(SECOND_BUILD)
})
it('reads two generations as no activation and drops the host tree', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
fs.seed(`${HOST}/generations/${'c'.repeat(64)}/manifest.json`, {
kind: 'file',
bytes: new TextEncoder().encode('{}')
})
expect(await store.readActiveGeneration(HOST)).toBeNull()
expect(fs.paths().some((path) => path.startsWith(HOST))).toBe(false)
})
it('reads an unparseable or mismatched manifest as no activation and drops the host tree', async () => {
for (const body of [
'not json',
JSON.stringify({ ...buildResult({}).manifest, buildId: 'd'.repeat(64) })
]) {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
fs.seed(`${HOST}/generations/${BUILD}/manifest.json`, {
kind: 'file',
bytes: new TextEncoder().encode(body)
})
expect(await store.readActiveGeneration(HOST)).toBeNull()
expect(fs.paths().some((path) => path.startsWith(HOST))).toBe(false)
}
})
it('evicts the least recently activated host past the ceiling', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const hosts = hostKeys(MAX_CACHED_HOSTS + 1)
for (const host of hosts) {
await activate(store, host)
}
expect(await store.readActiveGeneration(hosts[0])).toBeNull()
expect(fs.paths().some((path) => path.startsWith(hosts[0]))).toBe(false)
for (const host of hosts.slice(1)) {
expect((await store.readActiveGeneration(host))?.buildId).toBe(BUILD)
}
expect(fs.text('hosts.json')).toBe(JSON.stringify(hosts.slice(1)))
})
it('evicts a host with no index entry before the least recently activated one', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const [oldest, ...rest] = hostKeys(MAX_CACHED_HOSTS - 1)
const orphan = deriveHostCacheKey('orphan')
for (const host of [oldest, ...rest]) {
await activate(store, host)
}
await activate(store, orphan)
const order = [oldest, ...rest]
fs.seed('hosts.json', { kind: 'file', bytes: new TextEncoder().encode(JSON.stringify(order)) })
await activate(store, deriveHostCacheKey('one-too-many'))
expect(fs.paths().some((path) => path.startsWith(orphan))).toBe(false)
expect((await store.readActiveGeneration(oldest))?.buildId).toBe(BUILD)
})
it('counts a recommit of the build a host already has as use of that host', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const hosts = hostKeys(MAX_CACHED_HOSTS)
const [kept, evicted] = hosts
for (const host of hosts) {
await activate(store, host)
}
await activate(store, kept)
await activate(store, deriveHostCacheKey('one-too-many'))
expect(fs.paths().some((path) => path.startsWith(evicted))).toBe(false)
expect((await store.readActiveGeneration(kept))?.buildId).toBe(BUILD)
})
it('counts opening a cached generation as use of that host', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const hosts = hostKeys(MAX_CACHED_HOSTS)
for (const host of hosts) {
await activate(store, host)
}
expect((await store.readActiveGeneration(hosts[0]))?.buildId).toBe(BUILD)
await activate(store, deriveHostCacheKey('one-too-many'))
expect((await store.readActiveGeneration(hosts[0]))?.buildId).toBe(BUILD)
expect(fs.paths().some((path) => path.startsWith(hosts[1]))).toBe(false)
})
it('writes nothing when a read finds no active generation', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
const writesBefore = fs.writes.length
expect(await store.readActiveGeneration(deriveHostCacheKey('never-opened'))).toBeNull()
expect(fs.writes).toHaveLength(writesBefore)
expect(fs.text('hosts.json')).toBe(JSON.stringify([HOST]))
})
it('returns the generation when an open cannot write the index', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const other = deriveHostCacheKey('other')
await activate(store, HOST)
await activate(store, other)
fs.failWritesAt('hosts.json')
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(BUILD)
expect(fs.text('hosts.json')).toBe(JSON.stringify([HOST, other]))
})
it('rewrites the index on an open only when the host is not already the newest', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const [older, newest] = hostKeys(2)
await activate(store, older)
await activate(store, newest)
const before = { text: fs.text('hosts.json'), writes: fs.writes.length }
expect((await store.readActiveGeneration(newest))?.buildId).toBe(BUILD)
expect({ text: fs.text('hosts.json'), writes: fs.writes.length }).toEqual(before)
expect((await store.readActiveGeneration(older))?.buildId).toBe(BUILD)
expect(fs.text('hosts.json')).toBe(JSON.stringify([newest, older]))
})
it('reads the timestamp record earlier builds wrote as an empty order', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const other = deriveHostCacheKey('other')
fs.seed('hosts.json', {
kind: 'file',
bytes: new TextEncoder().encode(JSON.stringify({ [HOST]: 1, [other]: 2 }))
})
await activate(store, other)
expect(fs.text('hosts.json')).toBe(JSON.stringify([other]))
})
it('never counts or evicts a host that is only mid-download', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const hosts = hostKeys(MAX_CACHED_HOSTS)
const oldest = hosts[0]
const downloading = deriveHostCacheKey('downloading')
for (const host of hosts) {
await activate(store, host)
}
const staged = await store.stageGeneration(downloading, buildResult({}))
await activate(store, deriveHostCacheKey('one-too-many'))
// Count activations only; eviction must leave staged downloads intact.
expect(fs.paths().some((path) => path.startsWith(oldest))).toBe(false)
expect(fs.text(`${staged.directory.slice(ROOT.length + 1)}/manifest.json`)).not.toBeNull()
await store.commitGeneration(staged)
expect((await store.readActiveGeneration(downloading))?.buildId).toBe(BUILD)
})
it('serializes two stage calls for one host and build', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
// Give one build id conflicting asset lists to expose interleaved writes into the same staging directory.
const staging = `${HOST}/tmp/${BUILD}`
const earlier = buildResult({
buildId: BUILD,
assets: [{ path: 'assets/earlier.js', byteLength: 2 }]
})
const later = buildResult({
buildId: BUILD,
assets: [{ path: 'assets/later.js', byteLength: 3 }]
})
const [first, second] = await Promise.all([
store.stageGeneration(HOST, earlier),
store.stageGeneration(HOST, later)
])
expect(first.directory).toBe(second.directory)
expect(fs.writes).toEqual([
`${staging}/assets/earlier.js`,
`${staging}/manifest.json`,
`${staging}/assets/later.js`,
`${staging}/manifest.json`
])
expect(fs.paths().filter((path) => path.startsWith(`${staging}/assets/`))).toEqual([
`${staging}/assets/later.js`
])
})
it('drops residue from an earlier attempt instead of staging over it', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const staging = `${HOST}/tmp/${BUILD}`
fs.seed(`${staging}/assets/orphan.js`, { kind: 'file', bytes: new Uint8Array(1) })
await store.stageGeneration(HOST, buildResult({}))
expect(fs.paths().some((path) => path.endsWith('orphan.js'))).toBe(false)
})
it('refuses a path that escapes the staged tree, and a host key that is not one', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const escapes = [
'../outside.js',
'assets/../../outside.js',
'/etc/passwd',
'assets//app.js',
'manifest.json',
'Manifest.JSON'
]
for (const path of escapes) {
const result = buildResult({ assets: [{ path, byteLength: 1 }] })
await expect(store.stageGeneration(HOST, result)).rejects.toThrow('refuses to stage')
}
await expect(store.stageGeneration('host-a', buildResult({}))).rejects.toThrow(
'not a host cache key'
)
expect(fs.paths()).toEqual([])
})
it('deletes one host tree without touching another', async () => {
const fs = createFakeFileSystem()
const other = deriveHostCacheKey('host-b')
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
await activate(store, other)
await store.deleteHostCache(HOST)
expect(await store.readActiveGeneration(HOST)).toBeNull()
expect((await store.readActiveGeneration(other))?.buildId).toBe(BUILD)
expect(fs.text('hosts.json')).toBe(JSON.stringify([other]))
})
it('refuses a rename that did not carry the tree, as Android below API 26 can', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
fs.loseContentsOnMove()
const staged = await store.stageGeneration(HOST, buildResult({}))
await expect(store.commitGeneration(staged)).rejects.toThrow('did not carry its manifest')
expect(await store.readActiveGeneration(HOST)).toBeNull()
expect(fs.paths().some((path) => path.includes('generations/'))).toBe(false)
})
it('keeps the host tree when the manifest read fails, and drops it when it is missing', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const manifest = `${HOST}/generations/${BUILD}/manifest.json`
await activate(store, HOST)
const before = fs.paths()
fs.failReadsAt(manifest)
expect(await store.readActiveGeneration(HOST)).toBeNull()
expect(fs.paths()).toEqual(before)
fs.failReadsAt(null)
await fs.delete(`${ROOT}/${manifest}`)
expect(await store.readActiveGeneration(HOST)).toBeNull()
expect(fs.paths().some((path) => path.startsWith(HOST))).toBe(false)
})
it('activates normally when the recency index cannot be read', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
fs.seed('hosts.json', { kind: 'file', bytes: new TextEncoder().encode('[]') })
fs.failReadsAt('hosts.json')
await activate(store, HOST)
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(BUILD)
})
it('replaces an entry named for the build id that is not a readable generation', async () => {
const build = BUILD
// Model a crash between rename and the post-rename manifest check.
for (const seeded of [
{ kind: 'directory' },
{ kind: 'file', bytes: new Uint8Array(1) }
] as const) {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
fs.seed(`${HOST}/generations/${build}`, seeded)
await activate(store, HOST)
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(build)
expect(fs.text(`${HOST}/generations/${build}/index.html`)).not.toBeNull()
}
})
it('drops an aborted staging without touching the activation', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
const staged = await store.stageGeneration(HOST, buildResult(SECOND))
await store.abortStagedGeneration(staged)
expect(fs.paths().some((path) => path.includes(SECOND_BUILD))).toBe(false)
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(BUILD)
})
it('returns the activation even when the recency index cannot be written', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
fs.failWritesAt('hosts.json')
const staged = await store.stageGeneration(HOST, buildResult({}))
const active = await store.commitGeneration(staged)
expect(active.buildId).toBe(BUILD)
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(BUILD)
expect(fs.text('hosts.json')).toBeNull()
})
it('refuses a handle whose staged tree is gone without touching the activation', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
const staged = await store.stageGeneration(HOST, buildResult(SECOND))
await store.abortStagedGeneration(staged)
await expect(store.commitGeneration(staged)).rejects.toThrow('no longer on disk')
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(BUILD)
})
it('prunes an index entry whose host tree is gone', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const stale = deriveHostCacheKey('uninstalled')
fs.seed('hosts.json', {
kind: 'file',
bytes: new TextEncoder().encode(JSON.stringify([stale]))
})
await activate(store, HOST)
expect(fs.text('hosts.json')).toBe(JSON.stringify([HOST]))
})
it('refuses a staged handle it did not issue', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
const before = fs.paths()
const forged = {
hostKey: HOST,
buildId: SECOND_BUILD,
directory: `${ROOT}/${HOST}/generations/${BUILD}`,
manifest: buildResult({}).manifest
}
await expect(store.commitGeneration(forged)).rejects.toThrow('did not issue')
await expect(store.abortStagedGeneration(forged)).rejects.toThrow('did not issue')
expect(fs.paths()).toEqual(before)
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(BUILD)
})
it('ignores a directory under the cache root that is not a host key', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
fs.seed('not-a-host-key/stray.txt', { kind: 'file', bytes: new Uint8Array(1) })
const hosts = ['a', 'b', 'c', 'd'].map((name) => deriveHostCacheKey(name))
for (const host of hosts) {
await activate(store, host)
}
await store.sweepStagedGenerations()
expect(fs.paths()).toContain('not-a-host-key/stray.txt')
for (const host of hosts) {
expect((await store.readActiveGeneration(host))?.buildId).toBe(BUILD)
}
})
})
describe('reusable assets for the next download', () => {
const NEXT = buildResult({
assets: [
{ path: 'index.html', byteLength: 8 },
{ path: 'assets/app-2.js', byteLength: 2 }
]
})
const NEXT_BUILD = NEXT.manifest.buildId
const [PAGE, SCRIPT] = NEXT.manifest.assets
const DOWNLOADED = new Map([['index.html', new Uint8Array(8).fill(9)]])
const LEFT_TO_STAGE = { ...NEXT, assets: DOWNLOADED, reused: ['assets/app-2.js'] }
it('matches cached assets by content, and checks them without reading them', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
expect((await store.openReusableAssets(HOST)).has(SCRIPT!)).toBe(false)
await activate(store, HOST)
const reusable = await store.openReusableAssets(HOST)
expect(reusable.has(SCRIPT!)).toBe(true)
expect(reusable.has(PAGE!)).toBe(false)
expect(await reusable.check(SCRIPT!)).toBe(true)
fs.seed(`${HOST}/generations/${BUILD}/assets/app.js`, {
kind: 'file',
bytes: new Uint8Array(1)
})
expect(await reusable.check(SCRIPT!)).toBe(false)
})
it('copies the reused assets beside the downloaded ones and commits the whole build', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
await activate(store, HOST, LEFT_TO_STAGE)
const generation = `${HOST}/generations/${NEXT_BUILD}`
expect(fs.bytes(`${generation}/index.html`)).toEqual(new Uint8Array(8).fill(9))
expect(fs.bytes(`${generation}/assets/app-2.js`)).toEqual(new Uint8Array(2).fill(7))
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(NEXT_BUILD)
})
it('refuses to stage a reused asset the active generation no longer holds', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
fs.failReadsAt(`${HOST}/generations/${BUILD}/assets/app.js`)
await expect(store.stageGeneration(HOST, LEFT_TO_STAGE)).rejects.toThrow('unreadable')
await store.deleteHostCache(HOST)
fs.failReadsAt(null)
await expect(store.stageGeneration(HOST, LEFT_TO_STAGE)).rejects.toThrow(
'no longer holds the reused asset assets/app-2.js'
)
expect(fs.paths().some((path) => path.includes(`tmp/${NEXT_BUILD}`))).toBe(false)
})
})
// Older builds could leave manifest-next.json after an interrupted rewrite; this reader must ignore it.
describe('a pending manifest left by an older build', () => {
const MANIFEST_PATH = `${HOST}/generations/${BUILD}/manifest.json`
const NEXT_PATH = `${HOST}/generations/${BUILD}/manifest-next.json`
function seedPending(fs: FakeFileSystem): void {
const body = JSON.stringify({ ...buildResult({}).manifest, routes: [] })
fs.seed(NEXT_PATH, { kind: 'file', bytes: new TextEncoder().encode(body) })
}
it('reads the manifest beside it and leaves it inert', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
const stored = fs.text(MANIFEST_PATH)
seedPending(fs)
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(BUILD)
expect(fs.text(MANIFEST_PATH)).toBe(stored)
})
it('drops the host tree for a redownload when it is the only manifest left', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
seedPending(fs)
await fs.delete(`${ROOT}/${MANIFEST_PATH}`)
expect(await store.readActiveGeneration(HOST)).toBeNull()
expect(fs.paths().some((path) => path.startsWith(`${HOST}/generations`))).toBe(false)
})
})