diff --git a/packages/boxel-cli/src/commands/file/read.ts b/packages/boxel-cli/src/commands/file/read.ts index 27b7201f64f..aeeb254a330 100644 --- a/packages/boxel-cli/src/commands/file/read.ts +++ b/packages/boxel-cli/src/commands/file/read.ts @@ -6,14 +6,21 @@ import { } from '../../lib/profile-manager'; import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type'; +import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type'; import { FG_RED, DIM, RESET } from '../../lib/colors'; import { cliLog } from '../../lib/cli-log'; export interface ReadResult { ok: boolean; status?: number; - /** Raw text content of the file. */ + /** Raw text content of the file. Populated for non-binary paths. */ content?: string; + /** + * Raw bytes. Populated when the requested path is a binary filename + * (PNG, PDF, font, etc.) — see `isBinaryFilename`. Mutually exclusive + * with `content`. + */ + bytes?: Uint8Array; error?: string; } @@ -27,8 +34,10 @@ interface ReadCliOptions { } /** - * Read a file from a realm. Always returns the raw text content. - * Callers should parse the content themselves if needed (e.g. JSON). + * Read a file from a realm. Returns raw text in `content` for text files; + * returns raw bytes in `bytes` for binary files (PNG / PDF / font / etc., + * per `isBinaryFilename`). Callers should parse the content themselves + * if needed (e.g. JSON). * * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`. */ @@ -70,6 +79,11 @@ export async function read( }; } + if (isBinaryFilename(path)) { + let bytes = new Uint8Array(await response.arrayBuffer()); + return { ok: true, status: response.status, bytes }; + } + let text = await response.text(); return { ok: true, status: response.status, content: text }; } @@ -96,9 +110,30 @@ export function registerReadCommand(parent: Command): void { } if (opts.json) { - cliLog.output(JSON.stringify(result, null, 2)); + let serializable: Record = { + ok: result.ok, + status: result.status, + error: result.error, + }; + if (result.content !== undefined) { + serializable.content = result.content; + } + if (result.bytes !== undefined) { + // Buffer.from(typedArray) shares memory, then toString('base64') + // copies into a base64 string — fine for the JSON output path. + serializable.bytesBase64 = Buffer.from( + result.bytes.buffer, + result.bytes.byteOffset, + result.bytes.byteLength, + ).toString('base64'); + } + cliLog.output(JSON.stringify(serializable, null, 2)); } else if (result.ok) { - cliLog.output(result.content ?? ''); + if (result.bytes !== undefined) { + process.stdout.write(result.bytes); + } else { + cliLog.output(result.content ?? ''); + } } else { console.error( `${DIM}Status:${RESET} ${result.status ?? '(no status)'}`, diff --git a/packages/boxel-cli/src/commands/file/write.ts b/packages/boxel-cli/src/commands/file/write.ts index ff4ac1363d8..2765741f009 100644 --- a/packages/boxel-cli/src/commands/file/write.ts +++ b/packages/boxel-cli/src/commands/file/write.ts @@ -7,6 +7,7 @@ import { } from '../../lib/profile-manager'; import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type'; +import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type'; import { FG_GREEN, FG_RED, DIM, RESET } from '../../lib/colors'; import { cliLog } from '../../lib/cli-log'; @@ -26,15 +27,19 @@ interface WriteCliOptions { } /** - * Write a file to a realm. Content is sent as-is with card+source MIME type. - * Path should include the file extension. + * Write a file to a realm. Path should include the file extension. + * + * String content is sent with the card+source MIME type (the text path + * .gts / .json / .md / etc. always took). Binary content (a `Uint8Array`, + * including the `Buffer` subclass) is sent with `application/octet-stream`, + * which the realm-server routes to `upsertBinaryFile` and writes verbatim. * * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`. */ export async function write( realmUrl: string, path: string, - content: string, + content: string | Uint8Array, options?: WriteCommandOptions, ): Promise { let pm = options?.profileManager ?? getProfileManager(); @@ -47,15 +52,38 @@ export async function write( } let url = new URL(path, ensureTrailingSlash(realmUrl)).href; + let isBinary = typeof content !== 'string'; + + // Defense-in-depth for programmatic callers (BoxelClient.write, tests). + // The CLI wrapper has an earlier guard against `--file image.png` → + // `notes.md` style misuse, but the library function is also reachable + // without going through that branch. Reject the mismatch here so raw + // bytes never land at a text extension (corrupt-on-read) and a UTF-8 + // string never lands at a binary extension (corrupt-on-write). + let pathIsBinary = isBinaryFilename(path); + if (pathIsBinary !== isBinary) { + return { + ok: false, + error: + `Path ${path} is ${pathIsBinary ? 'binary' : 'text'} by extension ` + + `but content is ${isBinary ? 'bytes' : 'a string'}. ` + + `Refusing to write to avoid silent corruption.`, + }; + } try { let response = await pm.authedRealmFetch(url, { method: 'POST', - headers: { - Accept: SupportedMimeType.CardSource, - 'Content-Type': SupportedMimeType.CardSource, - }, - body: content, + headers: isBinary + ? { 'Content-Type': SupportedMimeType.OctetStream } + : { + Accept: SupportedMimeType.CardSource, + 'Content-Type': SupportedMimeType.CardSource, + }, + // Both branches of `content: string | Uint8Array` are valid + // BodyInit values, but TS narrows them as a union that doesn't + // unify against the fetch signature without a hint. + body: content as BodyInit, }); if (!response.ok) { @@ -103,10 +131,30 @@ export function registerWriteCommand(parent: Command): void { ) .option('--json', 'Output raw JSON response') .action(async (filePath: string, opts: WriteCliOptions) => { - let content: string; + let content: string | Uint8Array; if (opts.file) { + // Refuse a source/destination binary-classification mismatch + // (e.g., `write notes.md --file image.png`) — otherwise raw + // bytes would land at a text extension and corrupt-on-read. + const srcIsBinary = isBinaryFilename(opts.file); + const dstIsBinary = isBinaryFilename(filePath); + if (srcIsBinary !== dstIsBinary) { + stderr( + `${FG_RED}Error:${RESET} source file ${opts.file} is ${ + srcIsBinary ? 'binary' : 'text' + } but destination path ${filePath} is ${ + dstIsBinary ? 'binary' : 'text' + }. Refusing to write to avoid silent corruption — rename the destination to match.`, + ); + process.exit(1); + } try { - content = readFileSync(opts.file, 'utf-8'); + // Binary source files are read as raw bytes so write() can + // hand them to the realm unchanged; forcing utf-8 would + // corrupt PNG / PDF / font / etc. payloads silently. + content = srcIsBinary + ? readFileSync(opts.file) + : readFileSync(opts.file, 'utf-8'); } catch (err) { stderr( `${FG_RED}Error:${RESET} Could not read file: ${err instanceof Error ? err.message : String(err)}`, diff --git a/packages/boxel-cli/src/commands/realm/push.ts b/packages/boxel-cli/src/commands/realm/push.ts index caed5017643..28c744fd3a6 100644 --- a/packages/boxel-cli/src/commands/realm/push.ts +++ b/packages/boxel-cli/src/commands/realm/push.ts @@ -200,6 +200,23 @@ class RealmPusher extends RealmSyncBase { const result = await this.uploadFilesAtomic(filesToUpload, addPaths); + // Record every file the server actually wrote before surfacing + // errors. uploadFilesAtomic can return both `succeeded` and + // `error` when the atomic text batch lands but a per-file + // binary POST fails — dropping the manifest update in that + // case would force a re-add on the next push (409 cascade). + if (result.succeeded.length > 0) { + const uploaded = await Promise.all( + result.succeeded.map(async (rel) => ({ + rel, + hash: await computeFileHash(filesToUpload.get(rel)!), + })), + ); + for (const { rel, hash } of uploaded) { + newManifest.files[rel] = hash; + } + } + if (result.error) { uploadFailed = true; this.hasError = true; @@ -215,16 +232,6 @@ class RealmPusher extends RealmSyncBase { } console.error(` ${hint}`); } - } else if (result.succeeded.length > 0) { - const uploaded = await Promise.all( - result.succeeded.map(async (rel) => ({ - rel, - hash: await computeFileHash(filesToUpload.get(rel)!), - })), - ); - for (const { rel, hash } of uploaded) { - newManifest.files[rel] = hash; - } } } @@ -270,7 +277,11 @@ class RealmPusher extends RealmSyncBase { } } - if (!this.options.dryRun && !uploadFailed && filesToUpload.size > 0) { + // Refresh mtimes and save the manifest even on partial failure — + // newManifest.files only contains files the server actually wrote + // (unchanged carry-overs + succeeded uploads), so persisting it + // is always safe and avoids re-uploading text files that landed. + if (!this.options.dryRun && filesToUpload.size > 0) { try { const freshMtimes = await this.getRemoteMtimes(); for (const rel of Object.keys(newManifest.files)) { @@ -291,7 +302,7 @@ class RealmPusher extends RealmSyncBase { delete newManifest.remoteMtimes; } - if (!this.options.dryRun && !uploadFailed) { + if (!this.options.dryRun) { await saveManifest(this.options.localDir, newManifest); } diff --git a/packages/boxel-cli/src/commands/realm/sync.ts b/packages/boxel-cli/src/commands/realm/sync.ts index f192b65e791..728f73e5ad4 100644 --- a/packages/boxel-cli/src/commands/realm/sync.ts +++ b/packages/boxel-cli/src/commands/realm/sync.ts @@ -314,14 +314,16 @@ class RealmSyncer extends RealmSyncBase { } const result = await this.uploadFilesAtomic(filesToUpload, addPaths); + // Record every file the server actually wrote, even when other + // files in the same batch failed — see push.ts for the symmetric + // reasoning. + this.pushedFiles.push(...result.succeeded); if (result.error) { this.hasError = true; console.error(result.error.message); for (const entry of result.error.perFile) { console.error(` ${entry.path}: ${entry.title}`); } - } else { - this.pushedFiles.push(...result.succeeded); } } @@ -371,8 +373,12 @@ class RealmSyncer extends RealmSyncBase { ); } - // Phase 6: Update manifest - if (!this.options.dryRun && !this.hasError) { + // Phase 6: Update manifest. Persist even on partial failure — we + // only record hashes for files the server actually wrote + // (pushedFiles + pulledFiles), so the manifest stays consistent + // with the realm and the next sync won't re-attempt successful + // files. + if (!this.options.dryRun) { // Build updated hashes from prior manifest + current local files + executed ops. // Start with the previous manifest so that files deleted locally but not // propagated (no --delete) retain their entries and aren't re-pulled next sync. diff --git a/packages/boxel-cli/src/lib/realm-sync-base.ts b/packages/boxel-cli/src/lib/realm-sync-base.ts index b4fce59d972..dda2ac455a3 100644 --- a/packages/boxel-cli/src/lib/realm-sync-base.ts +++ b/packages/boxel-cli/src/lib/realm-sync-base.ts @@ -3,6 +3,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import ignoreModule from 'ignore'; import pLimit from 'p-limit'; +import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type'; const ignore = (ignoreModule as any).default || ignoreModule; type Ignore = ReturnType; @@ -42,10 +43,38 @@ function decodeAtomicResultId(id: string): string { } } +// Builds a structured upload error: the message embeds the response +// status + statusText + a snippet of the response body (the realm +// returns useful detail there — size limits, missing scopes, etc.), +// and a `status` property is attached so the batch helper can route +// the failure without re-parsing the message. +async function throwUploadError( + response: Response, + relativePath: string, +): Promise { + const bodyText = await response.text().catch(() => ''); + const message = `Failed to upload ${relativePath}: ${response.status} ${response.statusText}${ + bodyText ? ` — ${bodyText.slice(0, 200)}` : '' + }`; + const err = new Error(message) as Error & { + status?: number; + body?: string; + }; + err.status = response.status; + err.body = bodyText; + throw err; +} + +// Shared shape for per-file upload errors that need to bubble back to +// callers (push.ts / sync.ts) so they can format hints and decide which +// successes to persist alongside the failures. +type UploadFailure = { path: string; status: number; title: string }; + export const SupportedMimeType = { CardSource: 'application/vnd.card+source', DirectoryListing: 'application/vnd.api+json', Mtimes: 'application/vnd.api+json', + OctetStream: 'application/octet-stream', } as const; export interface SyncOptions { @@ -396,6 +425,12 @@ export abstract class RealmSyncBase { return; } + if (isBinaryFilename(relativePath)) { + await this.uploadBinaryFile(relativePath, localPath); + console.log(` Uploaded: ${relativePath}`); + return; + } + const content = await fs.readFile(localPath, 'utf8'); const url = this.buildFileUrl(relativePath); @@ -409,14 +444,39 @@ export abstract class RealmSyncBase { }); if (!response.ok) { - throw new Error( - `Failed to upload: ${response.status} ${response.statusText}`, - ); + await throwUploadError(response, relativePath); } console.log(` Uploaded: ${relativePath}`); } + // Uploads a single binary file (PNG, PDF, font, etc.) per the host + // pattern: a per-file POST with Content-Type: application/octet-stream + // and the raw bytes as the body. The realm-server routes octet-stream + // POSTs to upsertBinaryFile, which writes the bytes verbatim without + // any string conversion. Used by both uploadFile (single-shot) and + // uploadFilesAtomic (mixed-batch fallback for the binary entries it + // splits out of the atomic JSON payload). + protected async uploadBinaryFile( + relativePath: string, + localPath: string, + ): Promise { + const bytes = await fs.readFile(localPath); + const url = this.buildFileUrl(relativePath); + + const response = await this.authenticator.authedRealmFetch(url, { + method: 'POST', + headers: { + 'Content-Type': SupportedMimeType.OctetStream, + }, + body: bytes, + }); + + if (!response.ok) { + await throwUploadError(response, relativePath); + } + } + // Batched upload via the realm's /_atomic endpoint. Returns the set of // paths the server reported as written plus an optional error payload // when the whole batch was rejected. The atomic endpoint validates @@ -430,7 +490,7 @@ export abstract class RealmSyncBase { succeeded: string[]; error?: { status: number; - perFile: Array<{ path: string; status: number; title: string }>; + perFile: UploadFailure[]; message: string; }; }> { @@ -449,8 +509,137 @@ export abstract class RealmSyncBase { return { succeeded: [] }; } + // The /_atomic endpoint embeds each file's content inside a JSON + // `attributes.content` string, which can't carry raw binary bytes. + // Match the host pattern: keep /_atomic for text files only, and + // for each binary file fall back to a per-file octet-stream POST + // (the same wire format `uploadBinaryFile` uses for a single binary). + // The two batches run concurrently — neither helper rejects, so the + // outer Promise.all just joins their structured results. + const textEntries: Array<[string, string]> = []; + const binaryEntries: Array<[string, string]> = []; + for (const entry of entries) { + if (isBinaryFilename(entry[0])) { + binaryEntries.push(entry); + } else { + textEntries.push(entry); + } + } + + const [binaryOutcome, textOutcome] = await Promise.all([ + this.uploadBinaryBatch(binaryEntries), + this.uploadTextAtomic(textEntries, addPaths), + ]); + + const succeeded = [...textOutcome.succeeded, ...binaryOutcome.succeeded]; + + if (textOutcome.fatal) { + return { + succeeded, + error: { + status: textOutcome.fatal.status, + perFile: [...textOutcome.failed, ...binaryOutcome.failed], + message: textOutcome.fatal.message, + }, + }; + } + + if (binaryOutcome.failed.length > 0) { + return { + succeeded, + error: { + status: binaryOutcome.failed[0].status, + perFile: binaryOutcome.failed, + message: `Binary upload failed for ${binaryOutcome.failed.length} file(s)`, + }, + }; + } + + return { succeeded }; + } + + // Fan out the per-file octet-stream POSTs for the binary slice of an + // atomic batch. Each upload is wrapped in try/catch so a single failure + // is folded into the result instead of rejecting the fan-out; + // Promise.allSettled is used at the boundary as defense-in-depth so a + // future change that drops the inner catch still surfaces a structured + // failure rather than silently aborting other in-flight uploads. + private async uploadBinaryBatch( + binaryEntries: Array<[string, string]>, + ): Promise<{ succeeded: string[]; failed: UploadFailure[] }> { + if (binaryEntries.length === 0) { + return { succeeded: [], failed: [] }; + } + + const settled = await Promise.allSettled( + binaryEntries.map(([relativePath, localPath]) => + this.remoteLimit(async () => { + try { + await this.uploadBinaryFile(relativePath, localPath); + console.log(` Uploaded: ${relativePath}`); + return { relativePath, ok: true as const }; + } catch (err) { + const errWithStatus = err as { status?: number }; + const status = + typeof errWithStatus?.status === 'number' + ? errWithStatus.status + : 500; + const title = err instanceof Error ? err.message : String(err); + return { + relativePath, + ok: false as const, + status, + title, + }; + } + }), + ), + ); + + const succeeded: string[] = []; + const failed: UploadFailure[] = []; + for (let i = 0; i < settled.length; i++) { + const outcome = settled[i]; + if (outcome.status === 'fulfilled') { + if (outcome.value.ok) { + succeeded.push(outcome.value.relativePath); + } else { + failed.push({ + path: outcome.value.relativePath, + status: outcome.value.status, + title: outcome.value.title, + }); + } + } else { + failed.push({ + path: binaryEntries[i][0], + status: 500, + title: String(outcome.reason), + }); + } + } + + return { succeeded, failed }; + } + + // POST the text slice of a mixed batch to /_atomic and decode the + // result. `fatal` is set when the server rejected the whole batch + // (non-201) — callers map that to a top-level error message; `failed` + // carries the per-operation errors the server returned alongside. + private async uploadTextAtomic( + textEntries: Array<[string, string]>, + addPaths: Set, + ): Promise<{ + succeeded: string[]; + failed: UploadFailure[]; + fatal?: { status: number; message: string }; + }> { + if (textEntries.length === 0) { + return { succeeded: [], failed: [] }; + } + const operations = await Promise.all( - entries.map(async ([relativePath, localPath]) => { + textEntries.map(async ([relativePath, localPath]) => { const content = await fs.readFile(localPath, 'utf8'); return { op: addPaths.has(relativePath) @@ -478,27 +667,28 @@ export abstract class RealmSyncBase { body: JSON.stringify({ 'atomic:operations': operations }), }); + const hrefToRelative = new Map( + textEntries.map(([rel]) => [this.buildFileUrl(rel), rel]), + ); + if (response.status === 201) { const body = (await response.json()) as { 'atomic:results'?: Array<{ data?: { id?: string } }>; }; - const hrefToRelative = new Map( - entries.map(([rel]) => [this.buildFileUrl(rel), rel]), - ); // The realm normalizes hrefs: a path with a space goes out as // `Knowledge Articles/...` but comes back URL-encoded as // `Knowledge%20Articles/...`. Decode the response id before the // map lookup so we resolve back to the original relative path // instead of falling through to the raw encoded URL. - const succeeded = (body['atomic:results'] ?? []) + const atomicSucceeded = (body['atomic:results'] ?? []) .map((r) => r.data?.id) .filter((id): id is string => typeof id === 'string') .map((id) => decodeAtomicResultId(id)) .map((id) => hrefToRelative.get(id) ?? id); - for (const rel of succeeded) { + for (const rel of atomicSucceeded) { console.log(` Uploaded: ${rel}`); } - return { succeeded }; + return { succeeded: atomicSucceeded, failed: [] }; } let errorBody: { @@ -510,15 +700,12 @@ export abstract class RealmSyncBase { // ignore JSON parse failures — fall through to the generic message } - const perFile = (errorBody.errors ?? []).map((e) => { + const failed: UploadFailure[] = (errorBody.errors ?? []).map((e) => { const detail = e.detail ?? ''; const match = detail.match(/Resource (\S+) /); const href = match ? decodeAtomicResultId(match[1]) : ''; - const relMap = new Map( - entries.map(([rel]) => [this.buildFileUrl(rel), rel]), - ); return { - path: relMap.get(href) ?? href, + path: hrefToRelative.get(href) ?? href, status: e.status ?? response.status, title: e.title ?? 'Error', }; @@ -526,9 +713,9 @@ export abstract class RealmSyncBase { return { succeeded: [], - error: { + failed, + fatal: { status: response.status, - perFile, message: `Atomic upload failed: ${response.status} ${response.statusText}`, }, }; @@ -559,12 +746,16 @@ export abstract class RealmSyncBase { ); } - const content = await response.text(); - const localDir = path.dirname(localPath); await fs.mkdir(localDir, { recursive: true }); - await fs.writeFile(localPath, content, 'utf8'); + if (isBinaryFilename(relativePath)) { + const buffer = Buffer.from(await response.arrayBuffer()); + await fs.writeFile(localPath, buffer); + } else { + const content = await response.text(); + await fs.writeFile(localPath, content, 'utf8'); + } console.log(` Downloaded: ${relativePath}`); } diff --git a/packages/boxel-cli/tests/helpers/binary-fixtures.ts b/packages/boxel-cli/tests/helpers/binary-fixtures.ts new file mode 100644 index 00000000000..6fa2d4a31bb --- /dev/null +++ b/packages/boxel-cli/tests/helpers/binary-fixtures.ts @@ -0,0 +1,24 @@ +// Tiny 1x1 transparent PNG (67 bytes). Contains the PNG signature +// (0x89, 0x50, 0x4E, 0x47, ...), null bytes inside the IHDR chunk, and +// other non-UTF-8 byte sequences — exactly the bytes that get mangled +// when a binary file is round-tripped through `Buffer.toString('utf8')` +// and back. Used by every binary upload/download test to verify that +// the bytes survive the CLI path unchanged. +export const TINY_PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, + 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, + 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, + 0x60, 0x82, +]); + +// A small fake-PDF byte blob: real `%PDF-1.4` header followed by a few +// non-ASCII bytes and a trailer. The realm-server treats `.pdf` as +// binary (via `isBinaryFilename`) regardless of payload validity, so +// this is enough to exercise the binary code path for a second +// extension without bundling a real document. +export const TINY_PDF_BYTES = new Uint8Array([ + 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34, 0x0a, 0xe2, 0xe3, 0xcf, 0xd3, + 0x0a, 0x25, 0x25, 0x45, 0x4f, 0x46, 0x0a, +]); diff --git a/packages/boxel-cli/tests/integration/file-read.test.ts b/packages/boxel-cli/tests/integration/file-read.test.ts index b338c01fa40..8787b5ddb1b 100644 --- a/packages/boxel-cli/tests/integration/file-read.test.ts +++ b/packages/boxel-cli/tests/integration/file-read.test.ts @@ -12,6 +12,7 @@ import { setupTestProfile, TEST_REALM_SERVER_URL, } from '../helpers/integration'; +import { TINY_PNG_BYTES } from '../helpers/binary-fixtures'; let profileManager: ProfileManager; let cleanupProfile: () => void; @@ -101,6 +102,27 @@ describe('file read (integration)', () => { expect(result.error).toContain('404'); }); + it('reads a binary PNG byte-identically (returns bytes, not content)', async () => { + // Seed via direct octet-stream POST — startTestRealmServer's + // fileSystem option only accepts strings. + let pngUrl = `${realmUrl}image.png`; + let seed = await profileManager.authedRealmFetch(pngUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: TINY_PNG_BYTES, + }); + expect(seed.ok, `seed POST failed: ${seed.status}`).toBe(true); + + let result = await read(realmUrl, 'image.png', { profileManager }); + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + expect(result.content).toBeUndefined(); + expect(result.bytes).toBeDefined(); + expect(Buffer.from(result.bytes!).equals(Buffer.from(TINY_PNG_BYTES))).toBe( + true, + ); + }); + it('returns error result when no active profile', async () => { let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-')); let emptyManager = new ProfileManager(emptyDir); diff --git a/packages/boxel-cli/tests/integration/file-write.test.ts b/packages/boxel-cli/tests/integration/file-write.test.ts index 67bc21be8a8..46860c542d4 100644 --- a/packages/boxel-cli/tests/integration/file-write.test.ts +++ b/packages/boxel-cli/tests/integration/file-write.test.ts @@ -13,6 +13,7 @@ import { setupTestProfile, uniqueRealmName, } from '../helpers/integration'; +import { TINY_PNG_BYTES, TINY_PDF_BYTES } from '../helpers/binary-fixtures'; let profileManager: ProfileManager; let cleanupProfile: () => void; @@ -92,6 +93,35 @@ describe('file write (integration)', () => { expect((doc as any).data.attributes.title).toBe('Written Card'); }); + it('writes a PNG byte-identically and reads it back', async () => { + let writeResult = await write(realmUrl, 'image.png', TINY_PNG_BYTES, { + profileManager, + }); + expect(writeResult.ok, `write failed: ${writeResult.error}`).toBe(true); + + let response = await profileManager.authedRealmFetch( + `${realmUrl}image.png`, + { method: 'GET', headers: { Accept: 'application/vnd.card+source' } }, + ); + expect(response.ok).toBe(true); + let remote = Buffer.from(await response.arrayBuffer()); + expect(remote.equals(Buffer.from(TINY_PNG_BYTES))).toBe(true); + }); + + it('writes a PDF byte-identically', async () => { + let writeResult = await write(realmUrl, 'doc.pdf', TINY_PDF_BYTES, { + profileManager, + }); + expect(writeResult.ok).toBe(true); + + let response = await profileManager.authedRealmFetch(`${realmUrl}doc.pdf`, { + method: 'GET', + headers: { Accept: 'application/vnd.card+source' }, + }); + let remote = Buffer.from(await response.arrayBuffer()); + expect(remote.equals(Buffer.from(TINY_PDF_BYTES))).toBe(true); + }); + it('returns error result when no active profile', async () => { let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-')); let emptyManager = new ProfileManager(emptyDir); diff --git a/packages/boxel-cli/tests/integration/realm-pull.test.ts b/packages/boxel-cli/tests/integration/realm-pull.test.ts index d392a59e8bc..024954db83d 100644 --- a/packages/boxel-cli/tests/integration/realm-pull.test.ts +++ b/packages/boxel-cli/tests/integration/realm-pull.test.ts @@ -13,6 +13,7 @@ import { setupTestProfile, TEST_REALM_SERVER_URL, } from '../helpers/integration'; +import { TINY_PNG_BYTES } from '../helpers/binary-fixtures'; let profileManager: ProfileManager; let cleanupProfile: () => void; @@ -263,4 +264,28 @@ describe('realm pull (integration)', () => { expect(result.error).toBeDefined(); expect(result.files).toEqual([]); }); + + // --- Binary file downloads (CS-11075) --- + + it('pulls a binary PNG byte-identically', async () => { + let localDir = makeLocalDir(); + + // Seed the realm with raw bytes via the octet-stream endpoint (the + // canonical wire format the realm-server's upsertBinaryFile route + // expects). The startTestRealmServer fileSystem option only accepts + // strings, so we POST after server start. + let pngUrl = new URL('image.png', realmUrl).href; + let seedResponse = await profileManager.authedRealmFetch(pngUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: TINY_PNG_BYTES, + }); + expect(seedResponse.ok).toBe(true); + + await pull(realmUrl, localDir, { profileManager }); + + let localPath = path.join(localDir, 'image.png'); + let pulled = fs.readFileSync(localPath); + expect(pulled.equals(Buffer.from(TINY_PNG_BYTES))).toBe(true); + }); }); diff --git a/packages/boxel-cli/tests/integration/realm-push.test.ts b/packages/boxel-cli/tests/integration/realm-push.test.ts index f46e09b0008..fd19872c7f2 100644 --- a/packages/boxel-cli/tests/integration/realm-push.test.ts +++ b/packages/boxel-cli/tests/integration/realm-push.test.ts @@ -13,6 +13,7 @@ import { setupTestProfile, uniqueRealmName, } from '../helpers/integration'; +import { TINY_PNG_BYTES, TINY_PDF_BYTES } from '../helpers/binary-fixtures'; import type { ProfileManager } from '../../src/lib/profile-manager'; let profileManager: ProfileManager; @@ -31,6 +32,28 @@ function writeLocalFile(localDir: string, relPath: string, content: string) { fs.writeFileSync(fullPath, content); } +function writeLocalBytes(localDir: string, relPath: string, bytes: Uint8Array) { + let fullPath = path.join(localDir, relPath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, bytes); +} + +async function fetchRemoteBytes( + realmUrl: string, + relPath: string, +): Promise { + let url = buildFileUrl(realmUrl, relPath); + let response = await profileManager.authedRealmFetch(url, { + headers: { Accept: 'application/vnd.card+source' }, + }); + if (!response.ok) { + throw new Error( + `Fetching ${url} failed: ${response.status} ${response.statusText}`, + ); + } + return Buffer.from(await response.arrayBuffer()); +} + interface SyncManifest { realmUrl: string; files: Record; @@ -677,6 +700,183 @@ describe('realm push (integration)', () => { expect(manifest.files['card.gts']).toMatch(/^[0-9a-f]{32}$/); }); + // --- Binary file uploads (CS-11075) --- + + it('pushes a PNG file and reads it back byte-identical', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + writeLocalBytes(localDir, 'image.png', TINY_PNG_BYTES); + + await pushCommand(localDir, realmUrl, { profileManager }); + + let remote = await fetchRemoteBytes(realmUrl, 'image.png'); + expect(remote.equals(Buffer.from(TINY_PNG_BYTES))).toBe(true); + + let manifest = readManifest(localDir); + expect(manifest.files['image.png']).toMatch(/^[0-9a-f]{32}$/); + }); + + it('pushes a PDF file byte-identically', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + writeLocalBytes(localDir, 'doc.pdf', TINY_PDF_BYTES); + + await pushCommand(localDir, realmUrl, { profileManager }); + + let remote = await fetchRemoteBytes(realmUrl, 'doc.pdf'); + expect(remote.equals(Buffer.from(TINY_PDF_BYTES))).toBe(true); + }); + + it('mixed batch carves binary out of /_atomic but lands every file', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + writeLocalFile(localDir, 'card.gts', 'export const c = 1;\n'); + writeLocalFile(localDir, 'data.json', '{"x":1}\n'); + writeLocalBytes(localDir, 'image.png', TINY_PNG_BYTES); + writeLocalBytes(localDir, 'doc.pdf', TINY_PDF_BYTES); + + let fetchSpy = vi.spyOn(profileManager, 'authedRealmFetch'); + let atomicCalls: typeof fetchSpy.mock.calls; + let octetCalls: typeof fetchSpy.mock.calls; + try { + await pushCommand(localDir, realmUrl, { profileManager }); + atomicCalls = fetchSpy.mock.calls.filter(([input, init]) => { + let url = typeof input === 'string' ? input : (input as URL).href; + return url.endsWith('/_atomic') && init?.method === 'POST'; + }); + octetCalls = fetchSpy.mock.calls.filter(([, init]) => { + let contentType = + (init?.headers as Record | undefined)?.[ + 'Content-Type' + ] ?? ''; + return ( + init?.method === 'POST' && contentType === 'application/octet-stream' + ); + }); + } finally { + fetchSpy.mockRestore(); + } + + expect(atomicCalls.length).toBe(1); + // One octet-stream POST per binary file (image.png, doc.pdf). + expect(octetCalls.length).toBe(2); + + // Every file landed byte-identical on the server + expect(await fetchRemoteFile(realmUrl, 'card.gts')).toContain('c = 1'); + expect(await fetchRemoteFile(realmUrl, 'data.json')).toContain('"x":1'); + expect( + (await fetchRemoteBytes(realmUrl, 'image.png')).equals( + Buffer.from(TINY_PNG_BYTES), + ), + ).toBe(true); + expect( + (await fetchRemoteBytes(realmUrl, 'doc.pdf')).equals( + Buffer.from(TINY_PDF_BYTES), + ), + ).toBe(true); + + // Manifest tracks all four files + let manifest = readManifest(localDir); + expect(Object.keys(manifest.files).sort()).toEqual([ + 'card.gts', + 'data.json', + 'doc.pdf', + 'image.png', + ]); + }); + + it('records text successes in manifest when binary partially fails', async () => { + // Mixed batch where the per-file binary POST fails (stubbed 413) + // while the atomic text batch lands. The manifest must still + // record the text file that the server actually wrote — otherwise + // the next push sees it as missing-from-manifest and tries to + // re-add it, hitting a 409 against the existing remote. + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + writeLocalFile(localDir, 'card.gts', 'export const c = 1;\n'); + writeLocalBytes(localDir, 'image.png', TINY_PNG_BYTES); + + let realFetch = profileManager.authedRealmFetch.bind(profileManager); + let fetchSpy = vi + .spyOn(profileManager, 'authedRealmFetch') + .mockImplementation(async (input, init) => { + let url = typeof input === 'string' ? input : (input as URL).href; + let contentType = + (init?.headers as Record | undefined)?.[ + 'Content-Type' + ] ?? ''; + if ( + init?.method === 'POST' && + contentType === 'application/octet-stream' && + url.endsWith('/image.png') + ) { + return new Response('Payload Too Large', { + status: 413, + statusText: 'Payload Too Large', + }); + } + return realFetch(input, init); + }); + + // pushCommand exits 2 on any upload error; intercept so the test + // can observe state instead of being terminated. + let exitCode: number | undefined; + let exitSpy = vi.spyOn(process, 'exit').mockImplementation((( + code?: number, + ) => { + if (exitCode === undefined) exitCode = code; + return undefined as never; + }) as never); + + try { + await pushCommand(localDir, realmUrl, { profileManager }); + } finally { + fetchSpy.mockRestore(); + exitSpy.mockRestore(); + } + expect(exitCode).toBe(2); + + // The text file landed + expect(await fetchRemoteFile(realmUrl, 'card.gts')).toContain('c = 1'); + + // The manifest records the text success even though the binary failed + let manifest = readManifest(localDir); + expect(manifest.files['card.gts']).toMatch(/^[0-9a-f]{32}$/); + expect(manifest.files['image.png']).toBeUndefined(); + }); + + it('treats SVG as text — round-trips through /_atomic without corruption', async () => { + // SVG is XML, so isBinaryFilename returns false. Confirm it still + // rides the atomic batch path and comes back exactly. + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + let svg = ''; + writeLocalFile(localDir, 'icon.svg', svg); + + let fetchSpy = vi.spyOn(profileManager, 'authedRealmFetch'); + let octetCount: number; + try { + await pushCommand(localDir, realmUrl, { profileManager }); + octetCount = fetchSpy.mock.calls.filter(([, init]) => { + let ct = + (init?.headers as Record | undefined)?.[ + 'Content-Type' + ] ?? ''; + return ct === 'application/octet-stream'; + }).length; + } finally { + fetchSpy.mockRestore(); + } + + expect(octetCount).toBe(0); + expect(await fetchRemoteFile(realmUrl, 'icon.svg')).toBe(svg); + }); + it('fails cleanly when an out-of-band create causes an atomic 409', async () => { let realmUrl = await createTestRealm(); let localDir = makeLocalDir(); diff --git a/packages/boxel-cli/tests/integration/realm-watch.test.ts b/packages/boxel-cli/tests/integration/realm-watch.test.ts index b3e36bb7395..fbb50ea1fa7 100644 --- a/packages/boxel-cli/tests/integration/realm-watch.test.ts +++ b/packages/boxel-cli/tests/integration/realm-watch.test.ts @@ -25,6 +25,7 @@ import { setupJwtTestProfile, TEST_REALM_SERVER_URL, } from '../helpers/integration'; +import { TINY_PNG_BYTES } from '../helpers/binary-fixtures'; let profileManager: ProfileManager; let cleanupProfile: (() => void) | undefined; @@ -208,6 +209,27 @@ async function writeRemoteFile( await waitForRemoteVisibility(realm, relPath, 'present', { previousMtime }); } +async function writeRemoteBytes( + realm: string, + relPath: string, + bytes: Uint8Array, +): Promise { + let previousMtime = (await fetchRemoteMtimes(realm))[ + buildFileUrl(realm, relPath) + ]; + let response = await remoteMutation(realm, relPath, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: bytes, + }); + if (!response.ok) { + throw new Error( + `writeRemoteBytes ${relPath} failed: ${response.status} ${response.statusText}`, + ); + } + await waitForRemoteVisibility(realm, relPath, 'present', { previousMtime }); +} + async function deleteRemoteFile(realm: string, relPath: string): Promise { let response = await remoteMutation(realm, relPath, { method: 'DELETE', @@ -278,25 +300,30 @@ describe('realm watch (integration)', () => { debounceMs: 0, quiet: true, }); - await watcher.initialize(); - - let hasChanges = await watcher.poll(); - expect(hasChanges).toBe(true); - expect(watcher.pendingCount).toBeGreaterThanOrEqual(1); - - let result = await watcher.flushPending(); - expect(result.pulled).toContain(watchFixture('first-poll')); - expect( - fs.readFileSync(path.join(localDir, watchFixture('first-poll')), 'utf8'), - ).toContain('a = 1'); - - expect(result.checkpoint).not.toBeNull(); - expect(result.checkpoint!.source).toBe('remote'); - - let checkpoints = await new CheckpointManager(localDir).getCheckpoints(); - expect(checkpoints.length).toBe(1); - - watcher.shutdown(); + try { + await watcher.initialize(); + + let hasChanges = await watcher.poll(); + expect(hasChanges).toBe(true); + expect(watcher.pendingCount).toBeGreaterThanOrEqual(1); + + let result = await watcher.flushPending(); + expect(result.pulled).toContain(watchFixture('first-poll')); + expect( + fs.readFileSync( + path.join(localDir, watchFixture('first-poll')), + 'utf8', + ), + ).toContain('a = 1'); + + expect(result.checkpoint).not.toBeNull(); + expect(result.checkpoint!.source).toBe('remote'); + + let checkpoints = await new CheckpointManager(localDir).getCheckpoints(); + expect(checkpoints.length).toBe(1); + } finally { + watcher.shutdown(); + } }); it('detects remote modifications across ticks and pulls them', async () => { @@ -311,35 +338,37 @@ describe('realm watch (integration)', () => { debounceMs: 0, quiet: true, }); - await watcher.initialize(); - await watcher.poll(); - await watcher.flushPending(); - - expect( - fs.readFileSync(path.join(localDir, watchFixture('mod')), 'utf8'), - ).toContain('v = 1'); - - // Realm mtimes are second-precision — wait so the next write bumps it. - await sleep(1100); - await writeRemoteFile( - realmUrl, - watchFixture('mod'), - 'export const v = 2;\n', - ); + try { + await watcher.initialize(); + await watcher.poll(); + await watcher.flushPending(); - let hasChanges = await watcher.poll(); - expect(hasChanges).toBe(true); - let result = await watcher.flushPending(); - expect(result.pulled).toContain(watchFixture('mod')); - expect( - fs.readFileSync(path.join(localDir, watchFixture('mod')), 'utf8'), - ).toContain('v = 2'); + expect( + fs.readFileSync(path.join(localDir, watchFixture('mod')), 'utf8'), + ).toContain('v = 1'); - let checkpoints = await new CheckpointManager(localDir).getCheckpoints(); - // One per applied poll. - expect(checkpoints.length).toBe(2); + // Realm mtimes are second-precision — wait so the next write bumps it. + await sleep(1100); + await writeRemoteFile( + realmUrl, + watchFixture('mod'), + 'export const v = 2;\n', + ); - watcher.shutdown(); + let hasChanges = await watcher.poll(); + expect(hasChanges).toBe(true); + let result = await watcher.flushPending(); + expect(result.pulled).toContain(watchFixture('mod')); + expect( + fs.readFileSync(path.join(localDir, watchFixture('mod')), 'utf8'), + ).toContain('v = 2'); + + let checkpoints = await new CheckpointManager(localDir).getCheckpoints(); + // One per applied poll. + expect(checkpoints.length).toBe(2); + } finally { + watcher.shutdown(); + } }); it('detects remote deletions and removes the local copy', async () => { @@ -354,24 +383,26 @@ describe('realm watch (integration)', () => { debounceMs: 0, quiet: true, }); - await watcher.initialize(); - await watcher.poll(); - await watcher.flushPending(); - expect(fs.existsSync(path.join(localDir, watchFixture('doomed')))).toBe( - true, - ); - - await deleteRemoteFile(realmUrl, watchFixture('doomed')); + try { + await watcher.initialize(); + await watcher.poll(); + await watcher.flushPending(); + expect(fs.existsSync(path.join(localDir, watchFixture('doomed')))).toBe( + true, + ); - let hasChanges = await watcher.poll(); - expect(hasChanges).toBe(true); - let result = await watcher.flushPending(); - expect(result.deleted).toContain(watchFixture('doomed')); - expect(fs.existsSync(path.join(localDir, watchFixture('doomed')))).toBe( - false, - ); + await deleteRemoteFile(realmUrl, watchFixture('doomed')); - watcher.shutdown(); + let hasChanges = await watcher.poll(); + expect(hasChanges).toBe(true); + let result = await watcher.flushPending(); + expect(result.deleted).toContain(watchFixture('doomed')); + expect(fs.existsSync(path.join(localDir, watchFixture('doomed')))).toBe( + false, + ); + } finally { + watcher.shutdown(); + } }); it('groups bursts of remote changes into a single debounced flush', async () => { @@ -382,57 +413,59 @@ describe('realm watch (integration)', () => { debounceMs, quiet: true, }); - await watcher.initialize(); - await watcher.poll(); - await watcher.flushPending(); - - let flushes: Array<{ pulled: string[]; deleted: string[] }> = []; - let resolveFlush!: () => void; - let flushTimeout: ReturnType; - let flushSettled = new Promise((resolve, reject) => { - flushTimeout = setTimeout(() => { - reject( - new Error( - `debounced flush did not settle within ${debounceMs + 1_000}ms during "${currentTestName()}"`, - ), - ); - }, debounceMs + 1_000); - resolveFlush = () => { - clearTimeout(flushTimeout); - resolve(); + try { + await watcher.initialize(); + await watcher.poll(); + await watcher.flushPending(); + + let flushes: Array<{ pulled: string[]; deleted: string[] }> = []; + let resolveFlush!: () => void; + let flushTimeout: ReturnType; + let flushSettled = new Promise((resolve, reject) => { + flushTimeout = setTimeout(() => { + reject( + new Error( + `debounced flush did not settle within ${debounceMs + 1_000}ms during "${currentTestName()}"`, + ), + ); + }, debounceMs + 1_000); + resolveFlush = () => { + clearTimeout(flushTimeout); + resolve(); + }; + }); + let onFlush = (result: { pulled: string[]; deleted: string[] }) => { + flushes.push(result); + resolveFlush(); }; - }); - let onFlush = (result: { pulled: string[]; deleted: string[] }) => { - flushes.push(result); - resolveFlush(); - }; - await writeRemoteFile( - realmUrl, - watchFixture('burst-a'), - 'export const a = 1;\n', - ); - await watcher.poll(); - watcher.scheduleFlush(onFlush); - - await writeRemoteFile( - realmUrl, - watchFixture('burst-b'), - 'export const b = 2;\n', - ); - await watcher.poll(); - watcher.scheduleFlush(onFlush); + await writeRemoteFile( + realmUrl, + watchFixture('burst-a'), + 'export const a = 1;\n', + ); + await watcher.poll(); + watcher.scheduleFlush(onFlush); - await flushSettled; - await sleep(60); + await writeRemoteFile( + realmUrl, + watchFixture('burst-b'), + 'export const b = 2;\n', + ); + await watcher.poll(); + watcher.scheduleFlush(onFlush); - expect(flushes.length).toBe(1); - expect(flushes[0].pulled.sort()).toEqual([ - watchFixture('burst-a'), - watchFixture('burst-b'), - ]); + await flushSettled; + await sleep(60); - watcher.shutdown(); + expect(flushes.length).toBe(1); + expect(flushes[0].pulled.sort()).toEqual([ + watchFixture('burst-a'), + watchFixture('burst-b'), + ]); + } finally { + watcher.shutdown(); + } }); it('runs the watchRealms loop end-to-end and stops on AbortSignal', async () => { @@ -469,6 +502,27 @@ describe('realm watch (integration)', () => { expect(checkpoints[0].source).toBe('remote'); }); + it('pulls a remote PNG byte-identically (CS-11075)', async () => { + let localDir = makeLocalDir(); + await writeRemoteBytes(realmUrl, 'image.png', TINY_PNG_BYTES); + + let watcher = new RealmWatcher({ realmUrl, localDir }, profileManager, { + debounceMs: 0, + quiet: true, + }); + try { + await watcher.initialize(); + await watcher.poll(); + let result = await watcher.flushPending(); + expect(result.pulled).toContain('image.png'); + + let pulled = fs.readFileSync(path.join(localDir, 'image.png')); + expect(pulled.equals(Buffer.from(TINY_PNG_BYTES))).toBe(true); + } finally { + watcher.shutdown(); + } + }); + it('returns an error when the realm URL is unreachable', async () => { let localDir = makeLocalDir(); let result = await watchRealms( @@ -517,26 +571,28 @@ describe('realm watch (integration)', () => { debounceMs: 0, quiet: true, }); - await watcher.initialize(); - await watcher.poll(); - await watcher.flushPending(); - expect(fs.existsSync(path.join(localDir, watchFixture('survives')))).toBe( - true, - ); - - // Force the next poll to fail (simulating a transient fetch error). The - // file must remain on disk and `lastKnownMtimes` must be untouched, so a - // subsequent successful poll observes no change. - (watcher as any).getRemoteMtimes = async () => { - throw new Error('simulated network failure'); - }; - await expect(watcher.poll()).rejects.toThrow('simulated network failure'); - expect(watcher.pendingCount).toBe(0); - expect(fs.existsSync(path.join(localDir, watchFixture('survives')))).toBe( - true, - ); + try { + await watcher.initialize(); + await watcher.poll(); + await watcher.flushPending(); + expect(fs.existsSync(path.join(localDir, watchFixture('survives')))).toBe( + true, + ); - watcher.shutdown(); + // Force the next poll to fail (simulating a transient fetch error). The + // file must remain on disk and `lastKnownMtimes` must be untouched, so a + // subsequent successful poll observes no change. + (watcher as any).getRemoteMtimes = async () => { + throw new Error('simulated network failure'); + }; + await expect(watcher.poll()).rejects.toThrow('simulated network failure'); + expect(watcher.pendingCount).toBe(0); + expect(fs.existsSync(path.join(localDir, watchFixture('survives')))).toBe( + true, + ); + } finally { + watcher.shutdown(); + } }); it('blocks a second concurrent watch against the same localDir', async () => { @@ -617,10 +673,13 @@ describe('realm watch (integration)', () => { debounceMs: 0, quiet: true, }); - await primer.initialize(); - await primer.poll(); - await primer.flushPending(); - primer.shutdown(); + try { + await primer.initialize(); + await primer.poll(); + await primer.flushPending(); + } finally { + primer.shutdown(); + } let baseline = await new CheckpointManager(localDir).getCheckpoints(); expect(baseline.length).toBe(1); @@ -752,29 +811,31 @@ describe('realm watch (integration)', () => { debounceMs: 0, quiet: true, }); - await watcher.initialize(); - await watcher.poll(); - await watcher.flushPending(); - - await sleep(1100); - await writeRemoteFile( - realmUrl, - watchFixture('flip'), - 'export const x = 2;\n', - ); - await watcher.poll(); - expect(watcher.pendingCount).toBe(1); + try { + await watcher.initialize(); + await watcher.poll(); + await watcher.flushPending(); - await deleteRemoteFile(realmUrl, watchFixture('flip')); - await watcher.poll(); + await sleep(1100); + await writeRemoteFile( + realmUrl, + watchFixture('flip'), + 'export const x = 2;\n', + ); + await watcher.poll(); + expect(watcher.pendingCount).toBe(1); - let result = await watcher.flushPending(); - expect(result.deleted).toContain(watchFixture('flip')); - expect(result.pulled).not.toContain(watchFixture('flip')); - expect(fs.existsSync(path.join(localDir, watchFixture('flip')))).toBe( - false, - ); + await deleteRemoteFile(realmUrl, watchFixture('flip')); + await watcher.poll(); - watcher.shutdown(); + let result = await watcher.flushPending(); + expect(result.deleted).toContain(watchFixture('flip')); + expect(result.pulled).not.toContain(watchFixture('flip')); + expect(fs.existsSync(path.join(localDir, watchFixture('flip')))).toBe( + false, + ); + } finally { + watcher.shutdown(); + } }); });