feat: F5.5 large binary files as blobs on server F
From 16 KB a picture's or binary file's entry names its bytes by their SHA-256 instead of holding them; the bytes are on F, content-addressed (GET/POST /blobs with the session's grant, from the editor's origin, at most 16 MiB). A document's blob side says where they are: the editor's fetches and uploads, the room thread's asks F's main thread, and a start waits for the ones asked for; without a side everything stays in the document. Text stays Y.Text. A blob whose bytes are not there is never written back empty. A blob F has not got yet is asked for again a few times, and a running room reads late bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,8 @@ export interface ServerF {
|
||||
/** Signs a person in, in this browser context. */
|
||||
signIn(context: BrowserContext, project: string, person: { readonly name: string; readonly level?: 'read' | 'write'; readonly user?: string }): Promise<void>;
|
||||
service(method: string, path: string, body?: unknown): Promise<Response>;
|
||||
/** The size of a blob in F's store, none without it. */
|
||||
blobSize(sha: string): Promise<number | undefined>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -88,6 +90,10 @@ export async function startServerF(editorBase: string): Promise<ServerF> {
|
||||
if (response?.status() !== 200) throw new Error(`not signed in: ${response?.status()}`);
|
||||
await page.close();
|
||||
},
|
||||
async blobSize(sha) {
|
||||
const result = await sql.query<{ size: number }>('SELECT size FROM filius_blob WHERE sha = $1', [sha]);
|
||||
return result.rows[0]?.size;
|
||||
},
|
||||
service: (method, path, body) => fetch(`${base}${path}`, { method, headers: { 'x-filius-service-key': SERVICE_KEY }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }),
|
||||
async close() {
|
||||
const ended = new Promise((resolve) => child.once('exit', resolve));
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
// told, thawed he writes again; his grant ended, he is told and the editor
|
||||
// goes.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, readFileSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { expect, test, type Browser, type Page } from '@playwright/test';
|
||||
import { BLOB_THRESHOLD_BYTES } from '@mdg-filius/doc';
|
||||
import { drag, onDevice, serveEditor } from './editorPage.js';
|
||||
import { colorOf } from '../src/remote/presence.js';
|
||||
import { startServerF, type ServerF } from './serverF.js';
|
||||
@@ -158,6 +163,51 @@ test('three people in one project of F: moving, reading only, one simulation for
|
||||
await expect(canvas(mia)).toBeVisible();
|
||||
});
|
||||
|
||||
test('takes a large picture one person imports to F as a blob, and shows it to another', async ({ browser }) => {
|
||||
test.setTimeout(120_000);
|
||||
const project = await f.project('java-examples/internetwork_komplett.fls');
|
||||
const notebook = project.node('Notebook 1.10');
|
||||
const mia = await person(browser, project.id, 'Mia');
|
||||
const ben = await person(browser, project.id, 'Ben');
|
||||
// A picture far over 16 KB: a screenshot of the page.
|
||||
const file = join(mkdtempSync(join(tmpdir(), 'filius-blob-')), 'bild.png');
|
||||
await mia.screenshot({ path: file });
|
||||
const size = statSync(file).size;
|
||||
expect(size).toBeGreaterThan(BLOB_THRESHOLD_BYTES);
|
||||
await mia.locator('button[name="simulationMode"]').click();
|
||||
for (const page of [mia, ben]) await expect(canvas(page)).toHaveAttribute('data-mode', 'simulation');
|
||||
const open = async (page: Page) => {
|
||||
const at = await onDevice(page, notebook);
|
||||
await page.mouse.click(at.x, at.y);
|
||||
return desktop(page, notebook);
|
||||
};
|
||||
const miaDesktop = await open(mia);
|
||||
await miaDesktop.locator('[data-program="installation"]').click();
|
||||
const installation = miaDesktop.locator('[data-program-window="installation"]');
|
||||
for (const program of ['Datei-Explorer', 'Bildbetrachter']) await installation.locator('[data-list="available"] [role="option"]').filter({ hasText: program }).click();
|
||||
await installation.getByRole('button', { name: 'Installieren' }).click();
|
||||
await installation.getByRole('button', { name: 'Änderungen annehmen' }).click();
|
||||
await miaDesktop.locator('[data-program="fileExplorer"]').click();
|
||||
const explorer = miaDesktop.locator('[data-program-window="fileExplorer"]');
|
||||
await explorer.getByRole('toolbar', { name: 'Aktionen' }).getByRole('button', { name: 'Importieren' }).click();
|
||||
const importing = explorer.getByRole('dialog', { name: 'Datei importieren' });
|
||||
await importing.locator('input[type="file"]').setInputFiles(file);
|
||||
await importing.getByRole('button', { name: 'Datei importieren' }).click();
|
||||
await expect(importing.getByRole('status')).toHaveText('Datei erfolgreich importiert!');
|
||||
// Ben opens it in his viewer: the picture, as large as the page was.
|
||||
const benDesktop = await open(ben);
|
||||
await benDesktop.locator('[data-program="imageViewer"]').click();
|
||||
const viewer = benDesktop.locator('[data-program-window="imageViewer"]');
|
||||
await viewer.getByRole('button', { name: 'Öffnen' }).click();
|
||||
const dialog = viewer.getByRole('dialog', { name: 'Datei Öffnen' });
|
||||
await dialog.locator('.file-dialog-entries button', { hasText: 'bild.png' }).click();
|
||||
await dialog.getByRole('button', { name: 'Öffnen' }).click();
|
||||
await expect.poll(() => viewer.getByRole('img', { name: 'Bild' }).evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(mia.viewportSize()!.width);
|
||||
// In F's store under its SHA-256.
|
||||
const sha = createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
expect(await f.blobSize(sha)).toBe(size);
|
||||
});
|
||||
|
||||
test('tells the dashboard over the bridge who else is in the project, each time it changes', async ({ browser }) => {
|
||||
const project = await f.project('java-examples/zwei_rechnernetze_komplett.fls');
|
||||
const context = await browser.newContext();
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
type FolderEntry,
|
||||
type Id,
|
||||
} from '@mdg-filius/core';
|
||||
import { changeFiles, nodeFiles } from '@mdg-filius/doc';
|
||||
import { changeFiles, nodeFiles, onBlobsArrive } from '@mdg-filius/doc';
|
||||
import { WindowState, windowOf } from './windowState.js';
|
||||
|
||||
/** A row of the explorer's tree: every folder and file, each folder's entries after it in the order they were made. */
|
||||
@@ -214,6 +214,8 @@ export class ImageViewerWindow extends WindowState {
|
||||
) {
|
||||
super();
|
||||
doc.on('update', () => this.pathValue !== null && this.changed());
|
||||
// A picture that is a blob shows once its bytes have come.
|
||||
onBlobsArrive(doc, () => this.pathValue !== null && this.changed());
|
||||
}
|
||||
|
||||
private files(): VirtualFileSystem {
|
||||
|
||||
@@ -367,6 +367,7 @@
|
||||
"remote.refused": "Der Filius-Dienst hat die Verbindung abgelehnt ({reason}).",
|
||||
"remote.revoked": "Ihr Zugang zu diesem Projekt wurde beendet.",
|
||||
"remote.unknownProject": "Dieses Projekt gibt es nicht mehr.",
|
||||
"remote.uploadFailed": "Eine Datei konnte nicht zum Filius-Dienst gebracht werden; andere sehen sie nicht. Laden Sie die Seite neu und versuchen Sie es noch einmal.",
|
||||
"report.apps": "Anwendungen",
|
||||
"report.baseConfig": "Basiskonfiguration",
|
||||
"report.components": "Komponenten",
|
||||
|
||||
@@ -367,6 +367,7 @@
|
||||
"remote.refused": "The Filius service refused the connection ({reason}).",
|
||||
"remote.revoked": "Your access to this project has ended.",
|
||||
"remote.unknownProject": "This project no longer exists.",
|
||||
"remote.uploadFailed": "A file could not be taken to the Filius service; others do not see it. Reload the page and try again.",
|
||||
"report.apps": "Applications",
|
||||
"report.baseConfig": "Base Configuration",
|
||||
"report.components": "Components",
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
// refuses or lets go — of the document or the room — a word why, instead of
|
||||
// the editor. Who only reads gets the editor locked (Editor's readOnly).
|
||||
// The others in the project are shown in the editor and told to whoever
|
||||
// embeds it (`onPeople`: the dashboard's header, over the bridge).
|
||||
// embeds it (`onPeople`: the dashboard's header, over the bridge). The
|
||||
// document's large binary files come from F and go to it (remoteBlobs):
|
||||
// fetched as the document names them; one that could not be taken to F is
|
||||
// said.
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { attachBlobs } from '@mdg-filius/doc';
|
||||
import { remoteBlobs, type RemoteBlobs } from './remoteBlobs.js';
|
||||
import { peopleOf, type PresenceUser } from './presence.js';
|
||||
import { usePresence } from './usePresence.js';
|
||||
import { Editor } from '../Editor.js';
|
||||
@@ -18,7 +23,25 @@ import { connectSharedDoc, type SharedDocState } from './sharedDoc.js';
|
||||
type Ready = Extract<SharedDocState, { kind: 'ready' }>;
|
||||
|
||||
/** The editor on the ready document, with the others in the project. */
|
||||
function ReadyEditor({ state, createSimHost, onPeople }: { readonly state: Ready; readonly createSimHost: SimHostFactory; readonly onPeople: ((people: readonly PresenceUser[]) => void) | undefined }) {
|
||||
function ReadyEditor({
|
||||
state,
|
||||
createSimHost,
|
||||
onPeople,
|
||||
blobs,
|
||||
}: {
|
||||
readonly state: Ready;
|
||||
readonly createSimHost: SimHostFactory;
|
||||
readonly onPeople: ((people: readonly PresenceUser[]) => void) | undefined;
|
||||
readonly blobs: RemoteBlobs;
|
||||
}) {
|
||||
// The blob side before anything reads or writes a file of the document.
|
||||
useMemo(() => attachBlobs(state.doc, blobs), [state.doc, blobs]);
|
||||
useEffect(() => {
|
||||
const fetchMissing = () => void blobs.fetchMissing(state.doc);
|
||||
fetchMissing();
|
||||
state.doc.on('update', fetchMissing);
|
||||
return () => state.doc.off('update', fetchMissing);
|
||||
}, [state.doc, blobs]);
|
||||
const presence = usePresence(state.awareness, state.you);
|
||||
const key = JSON.stringify(peopleOf(presence.peers, state.you.id));
|
||||
useEffect(() => onPeople?.(JSON.parse(key) as PresenceUser[]), [key, onPeople]);
|
||||
@@ -39,6 +62,8 @@ export function SharedProject({ server, project, onPeople }: SharedProjectProps)
|
||||
const [state, setState] = useState<SharedDocState>({ kind: 'connecting' });
|
||||
/** F ended the room for this page: the editor goes as when the document is let go. */
|
||||
const [roomEnd, setRoomEnd] = useState<string | null>(null);
|
||||
const [uploadFailed, setUploadFailed] = useState(false);
|
||||
const blobs = useMemo(() => remoteBlobs({ server, project, onFailure: () => setUploadFailed(true) }), [server, project]);
|
||||
useEffect(() => connectSharedDoc({ server, project, onState: setState }), [server, project]);
|
||||
const createSimHost = useMemo<SimHostFactory>(
|
||||
() => () => {
|
||||
@@ -63,5 +88,14 @@ export function SharedProject({ server, project, onPeople }: SharedProjectProps)
|
||||
{t('remote.connecting')}
|
||||
</p>
|
||||
);
|
||||
return <ReadyEditor key={state.doc.guid} state={state} createSimHost={createSimHost} onPeople={onPeople} />;
|
||||
return (
|
||||
<>
|
||||
{uploadFailed ? (
|
||||
<p role="alert" className="remote-notice">
|
||||
{t('remote.uploadFailed')}
|
||||
</p>
|
||||
) : null}
|
||||
<ReadyEditor key={state.doc.guid} state={state} createSimHost={createSimHost} onPeople={onPeople} blobs={blobs} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// The editor's blob side on F (packages/doc's blobs.ts): the blobs the
|
||||
// document names are fetched from F as they come into it (each once, with
|
||||
// the cookie; one F has not got — yet: the editor that made it may still be
|
||||
// taking it there — is asked for again a few times, a while apart; one the
|
||||
// network lost is asked for with the next change). A blob made here (a file
|
||||
// put into a computer) is kept at once and taken to F — tried again once
|
||||
// after a while, then told as failed.
|
||||
// What arrives is told to the document's listeners, so that a picture
|
||||
// shows once its bytes are here.
|
||||
|
||||
import type * as Y from 'yjs';
|
||||
import { memoryBlobSide, missingBlobs, type BlobSide } from '@mdg-filius/doc';
|
||||
|
||||
/** The wait before an upload is tried again. */
|
||||
export const UPLOAD_RETRY_MILLISECONDS = 2_000;
|
||||
/** How often, and how long apart, a blob F has not got is asked for again. */
|
||||
export const BLOB_ASKS = 5;
|
||||
export const BLOB_ASK_AGAIN_MILLISECONDS = 2_000;
|
||||
|
||||
export interface RemoteBlobsOptions {
|
||||
/** F's address. */
|
||||
readonly server: string;
|
||||
readonly project: string;
|
||||
/** A blob that could not be taken to F. */
|
||||
readonly onFailure?: (sha: string) => void;
|
||||
readonly fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface RemoteBlobs extends BlobSide {
|
||||
/** Fetches what the document names and is not here; done when every answer has come. */
|
||||
fetchMissing(doc: Y.Doc): Promise<void>;
|
||||
}
|
||||
|
||||
export function remoteBlobs({ server, project, onFailure, fetch: request = (input, init) => fetch(input, init) }: RemoteBlobsOptions): RemoteBlobs {
|
||||
const address = (path: string) => `${new URL(path, server).href}?project=${encodeURIComponent(project)}`;
|
||||
const upload = async (sha: string, bytes: Uint8Array, tries: number): Promise<void> => {
|
||||
try {
|
||||
const response = await request(address('/blobs'), { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/octet-stream' }, body: bytes as BodyInit });
|
||||
const answer = response.ok ? ((await response.json()) as { sha?: unknown }) : undefined;
|
||||
if (answer?.sha === sha) return;
|
||||
} catch {
|
||||
// Tried again below.
|
||||
}
|
||||
if (tries > 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, UPLOAD_RETRY_MILLISECONDS));
|
||||
return upload(sha, bytes, tries - 1);
|
||||
}
|
||||
onFailure?.(sha);
|
||||
};
|
||||
const side = memoryBlobSide((sha, bytes) => void upload(sha, bytes, 2));
|
||||
const asked = new Set<string>();
|
||||
/** Blobs F has not got (not asked for until their wait is over), and how often F said so. */
|
||||
const unavailable = new Set<string>();
|
||||
const misses = new Map<string, number>();
|
||||
const blobs: RemoteBlobs = {
|
||||
bytes: side.bytes,
|
||||
keep: side.keep,
|
||||
onArrive: side.onArrive,
|
||||
async fetchMissing(doc) {
|
||||
const wanted = missingBlobs(doc).filter((sha) => !asked.has(sha) && !unavailable.has(sha));
|
||||
for (const sha of wanted) asked.add(sha);
|
||||
const found = await Promise.all(
|
||||
wanted.map(async (sha): Promise<[string, Uint8Array] | undefined> => {
|
||||
try {
|
||||
const response = await request(address(`/blobs/${sha}`), { credentials: 'include' });
|
||||
if (response.ok) return [sha, new Uint8Array(await response.arrayBuffer())];
|
||||
if (response.status === 404) {
|
||||
const asks = (misses.get(sha) ?? 0) + 1;
|
||||
misses.set(sha, asks);
|
||||
unavailable.add(sha);
|
||||
if (asks < BLOB_ASKS)
|
||||
setTimeout(() => {
|
||||
unavailable.delete(sha);
|
||||
void blobs.fetchMissing(doc);
|
||||
}, BLOB_ASK_AGAIN_MILLISECONDS);
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
asked.delete(sha);
|
||||
}
|
||||
}),
|
||||
);
|
||||
side.arrived(new Map(found.filter((each) => each !== undefined)));
|
||||
},
|
||||
};
|
||||
return blobs;
|
||||
}
|
||||
@@ -396,3 +396,19 @@
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Over the editor on a project of F: something went wrong that the person should know. */
|
||||
.remote-notice {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 16px;
|
||||
z-index: 100;
|
||||
transform: translateX(-50%);
|
||||
max-width: min(90vw, 560px);
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 8px;
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger-on-soft);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// The editor's blob side on F (remoteBlobs), on a fetch the test answers: a
|
||||
// blob made here is kept at once and taken to F with the cookie, tried
|
||||
// again once, then told as failed (also when F names it otherwise); the
|
||||
// blobs a document names are fetched once each and told as they come; one F
|
||||
// has not got is not asked for again, one the network lost is.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
import { attachBlobs, BLOB_THRESHOLD_BYTES, blobSha, changeFiles, emptyProject, memoryBlobSide, missingBlobs, ROOTS, writeProject } from '@mdg-filius/doc';
|
||||
import { BLOB_ASK_AGAIN_MILLISECONDS, BLOB_ASKS, remoteBlobs, UPLOAD_RETRY_MILLISECONDS } from '../src/remote/remoteBlobs.js';
|
||||
|
||||
const picture = (seed: number) => Uint8Array.from({ length: BLOB_THRESHOLD_BYTES }, (_, index) => (index + seed) % 256);
|
||||
|
||||
interface Call {
|
||||
readonly url: string;
|
||||
readonly init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
function fakeFetch(answer: (call: Call) => Response | Promise<Response>) {
|
||||
const calls: Call[] = [];
|
||||
const fetch = (async (url: string, init?: RequestInit) => {
|
||||
const call = { url, init };
|
||||
calls.push(call);
|
||||
return answer(call);
|
||||
}) as typeof globalThis.fetch;
|
||||
return { calls, fetch };
|
||||
}
|
||||
|
||||
/** A document naming these pictures as blobs on a computer, made with a side of its own. */
|
||||
function docNaming(...pictures: Uint8Array[]): Y.Doc {
|
||||
const made = new Y.Doc();
|
||||
attachBlobs(made, memoryBlobSide());
|
||||
writeProject(made, emptyProject());
|
||||
made.transact(() => made.getMap<Y.Map<unknown>>(ROOTS.nodes).set('pc', new Y.Map()));
|
||||
changeFiles(made, 'pc', 'person', (files) => pictures.forEach((each, index) => files.writeImageFile(`/p${index}.png`, each, 'image/png')));
|
||||
const copy = new Y.Doc();
|
||||
Y.applyUpdate(copy, Y.encodeStateAsUpdate(made));
|
||||
return copy;
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("the editor's blobs on F", () => {
|
||||
it('keeps a blob at once and takes it to F with the cookie', async () => {
|
||||
const bytes = picture(1);
|
||||
const { calls, fetch } = fakeFetch(() => Response.json({ sha: blobSha(bytes) }, { status: 201 }));
|
||||
const failed: string[] = [];
|
||||
const side = remoteBlobs({ server: 'https://f.test/', project: 'p 1', fetch, onFailure: (sha) => failed.push(sha) });
|
||||
side.keep(blobSha(bytes), bytes);
|
||||
expect(side.bytes(blobSha(bytes))).toBe(bytes);
|
||||
await vi.runAllTimersAsync();
|
||||
expect(calls.map(({ url, init }) => [url, init?.method, init?.credentials, init?.body])).toEqual([['https://f.test/blobs?project=p%201', 'POST', 'include', bytes]]);
|
||||
expect(failed).toEqual([]);
|
||||
// Kept once: the same again goes nowhere.
|
||||
side.keep(blobSha(bytes), bytes);
|
||||
await vi.runAllTimersAsync();
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('tries again once after a while, then says it failed — also when F names the bytes otherwise', async () => {
|
||||
for (const answer of [() => new Response('', { status: 500 }), () => Response.json({ sha: 'other' }), () => Promise.reject(new Error('offline'))]) {
|
||||
const bytes = picture(2);
|
||||
const { calls, fetch } = fakeFetch(answer);
|
||||
const failed: string[] = [];
|
||||
remoteBlobs({ server: 'https://f.test', project: 'p', fetch, onFailure: (sha) => failed.push(sha) }).keep(blobSha(bytes), bytes);
|
||||
await vi.advanceTimersByTimeAsync(UPLOAD_RETRY_MILLISECONDS - 1);
|
||||
expect(calls).toHaveLength(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await vi.runAllTimersAsync();
|
||||
expect([calls.length, failed]).toEqual([2, [blobSha(bytes)]]);
|
||||
}
|
||||
// A second try that works says nothing.
|
||||
const bytes = picture(3);
|
||||
let tries = 0;
|
||||
const { fetch } = fakeFetch(() => (++tries === 1 ? new Response('', { status: 503 }) : Response.json({ sha: blobSha(bytes) })));
|
||||
const failed: string[] = [];
|
||||
remoteBlobs({ server: 'https://f.test', project: 'p', fetch, onFailure: (sha) => failed.push(sha) }).keep(blobSha(bytes), bytes);
|
||||
await vi.runAllTimersAsync();
|
||||
expect([tries, failed]).toEqual([2, []]);
|
||||
});
|
||||
|
||||
it('fetches the blobs a document names once each, and tells when they came', async () => {
|
||||
const [first, second] = [picture(4), picture(5)];
|
||||
const doc = docNaming(first, second);
|
||||
const { calls, fetch } = fakeFetch(({ url }) => new Response(url.includes(blobSha(first)) ? first : second));
|
||||
const side = remoteBlobs({ server: 'https://f.test', project: 'p', fetch });
|
||||
attachBlobs(doc, side);
|
||||
let heard = 0;
|
||||
side.onArrive(() => (heard += 1));
|
||||
expect(missingBlobs(doc).sort()).toEqual([blobSha(first), blobSha(second)].sort());
|
||||
await Promise.all([side.fetchMissing(doc), side.fetchMissing(doc)]);
|
||||
expect(calls.map(({ url, init }) => [url, init?.credentials]).sort()).toEqual(
|
||||
[
|
||||
[`https://f.test/blobs/${blobSha(first)}?project=p`, 'include'],
|
||||
[`https://f.test/blobs/${blobSha(second)}?project=p`, 'include'],
|
||||
].sort(),
|
||||
);
|
||||
expect([missingBlobs(doc), heard]).toEqual([[], 1]);
|
||||
expect(side.bytes(blobSha(first))).toEqual(first);
|
||||
});
|
||||
|
||||
it('asks again a few times, a while apart, for a blob F has not got yet; for one the network lost with the next change', async () => {
|
||||
const [late, gone, lost] = [picture(6), picture(7), picture(8)];
|
||||
const doc = docNaming(late, gone, lost);
|
||||
let offline = true;
|
||||
let lateAsked = 0;
|
||||
const { calls, fetch } = fakeFetch(({ url }) => {
|
||||
if (url.includes(blobSha(late))) return ++lateAsked < 3 ? new Response('', { status: 404 }) : new Response(late);
|
||||
if (url.includes(blobSha(gone))) return new Response('', { status: 404 });
|
||||
if (offline) return Promise.reject(new Error('offline'));
|
||||
return new Response(lost);
|
||||
});
|
||||
const side = remoteBlobs({ server: 'https://f.test', project: 'p', fetch });
|
||||
attachBlobs(doc, side);
|
||||
await side.fetchMissing(doc);
|
||||
offline = false;
|
||||
// Without a change, only what F has not got is asked for again.
|
||||
await vi.advanceTimersByTimeAsync(BLOB_ASK_AGAIN_MILLISECONDS - 1);
|
||||
expect(calls).toHaveLength(3);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await vi.runAllTimersAsync();
|
||||
expect(side.bytes(blobSha(late))).toEqual(late);
|
||||
expect(calls.filter(({ url }) => url.includes(blobSha(late)))).toHaveLength(3);
|
||||
expect(calls.filter(({ url }) => url.includes(blobSha(gone)))).toHaveLength(BLOB_ASKS);
|
||||
// The one the network lost comes with the next change (its fetch then asks for what is still missing).
|
||||
await side.fetchMissing(doc);
|
||||
expect(side.bytes(blobSha(lost))).toEqual(lost);
|
||||
expect(missingBlobs(doc)).toEqual([blobSha(gone)]);
|
||||
// Given up on: not asked for again.
|
||||
await side.fetchMissing(doc);
|
||||
await vi.runAllTimersAsync();
|
||||
expect(calls.filter(({ url }) => url.includes(blobSha(gone)))).toHaveLength(BLOB_ASKS);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user