mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
* [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>
656 lines
25 KiB
TypeScript
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)
|
|
})
|
|
})
|