diff --git a/src/docs/src/playground/examples/build-an-expense-report-from-receipts.html b/src/docs/src/playground/examples/build-an-expense-report-from-receipts.html
index d5bda343c..ccb1afee3 100644
--- a/src/docs/src/playground/examples/build-an-expense-report-from-receipts.html
+++ b/src/docs/src/playground/examples/build-an-expense-report-from-receipts.html
@@ -1,21 +1,80 @@
+
+
+
+
+ Pick a PNG, JPEG, or WebP receipt image. Nothing is sent until you pick one.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Saving corrections for later, discarding reviews, and recovering earlier saves are in the recipe.
- Reading the sample receipt...
diff --git a/src/docs/src/recipes/build-an-expense-report-from-receipts.md b/src/docs/src/recipes/build-an-expense-report-from-receipts.md
index 45a803483..434e5f5d1 100644
--- a/src/docs/src/recipes/build-an-expense-report-from-receipts.md
+++ b/src/docs/src/recipes/build-an-expense-report-from-receipts.md
@@ -6,23 +6,122 @@ order: 71
---
An expense report usually starts as a pile of receipt photos, and typing in each
-merchant, date, and total by hand is slow. In this recipe,
-[`puter.ai.chat()`](/AI/chat/) reads one receipt image and suggests four fields.
-The user checks them in your app, and only approved expenses go into the report.
-The report is saved with [`puter.kv`](/KV/), a key-value store in the user's
-Puter account, so an unfinished report is still there when the user comes back.
-The finished report is exported as a CSV file. The receipt image is sent to the
-AI but never saved.
+merchant, date, and total by hand is slow. In this recipe, the user picks a
+receipt photo from their Puter account, [`puter.ai.chat()`](/AI/chat/) reads it
+and suggests four fields, and the user checks them in your app. Only approved
+expenses go into the report. The report is saved with [`puter.kv`](/KV/), a
+key-value store in the user's Puter account, so an unfinished report is still
+there when the user comes back. The finished report is saved as a CSV file in
+the user's Puter account. The receipt image is sent to the AI but never saved.
+
+## Pick a Receipt Image
+
+The user picks the photo with
+[`puter.ui.showOpenFilePicker()`](/UI/showOpenFilePicker/), which opens the
+Puter file picker on the user's Desktop folder and resolves to an
+[`FSItem`](/Objects/fsitem/) for the chosen file. The
+[`read()`](/Objects/fsitem/#read) method of that item resolves to a `Blob` with
+the file's contents. When the user closes the picker in the Puter desktop, the
+promise from [`puter.ui.showOpenFilePicker()`](/UI/showOpenFilePicker/) stays
+pending, and its `undefinedOnCancel` promise resolves to `undefined` instead.
+When your app runs as a website, the picker opens in a popup window. Closing
+that popup resolves the picker's own promise to an object whose `msg` is
+`'fileOpenCanceled'`. The `pickReceiptImage()` function waits for whichever
+settles first and treats both forms as a cancel, so it resolves to `null` when
+the user cancels and rejects when the picker fails.
+
+The `accept: 'image/*'` option only filters what the picker shows, so the
+function checks the file itself. It accepts one PNG, JPEG, or WebP file of at
+most 3,700,000 bytes, going by its extension, the type and size of the `Blob` it
+reads, and the first bytes of the image. Storage can report any file as
+`application/octet-stream`, so that type is accepted when the extension and the
+first bytes match. The function then turns the image into a base64 data URL of
+the right type with
+[`FileReader.readAsDataURL()`](https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL).
+The byte cap keeps the data URL under the 5,000,000-character cap in
+[Check the Image](#check-the-image):
+
+```js;build-an-expense-report-from-receipts
+const IMAGE_TYPES = new Map([['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'], ['webp', 'image/webp']]);
+// Types storage can report for any file, accepted when the extension and the first bytes match.
+const GENERIC_TYPES = ['', 'application/octet-stream'];
+const IMAGE_SIGNATURES = {
+ 'image/png': /^\x89PNG\r\n\x1A\n/,
+ 'image/jpeg': /^\xFF\xD8\xFF/,
+ 'image/webp': /^RIFF[\s\S]{4}WEBP/,
+};
+// Base64 adds a third, so this keeps the data URL under the cap of checkImage().
+const MAX_IMAGE_BYTES = 3_700_000;
+const NOT_AN_IMAGE = 'Pick one PNG, JPEG, or WebP image.';
+const IMAGE_TOO_LARGE = 'Pick an image of at most 3,700,000 bytes. Scale a larger photo down first.';
+
+function readAsDataUrl (blob) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = () => reject(reader.error ?? new Error('The image could not be read.'));
+ reader.readAsDataURL(blob);
+ });
+}
+
+async function pickReceiptImage () {
+ const request = puter.ui.showOpenFilePicker({ multiple: false, accept: 'image/*' });
+ // A failure only rejects the picker's own promise. A desktop cancel settles undefinedOnCancel,
+ // and a website popup cancel resolves the picker's own promise with a cancel message.
+ const file = await Promise.race([request, request.undefinedOnCancel]);
+ if ( file === undefined || file?.msg === 'fileOpenCanceled' ) {
+ return null;
+ }
+ const type = IMAGE_TYPES.get(/\.([a-z]+)$/i.exec(String(file?.name))?.[1].toLowerCase());
+ if ( Array.isArray(file) || file?.isDir || typeof file?.read !== 'function' || type === undefined ) {
+ throw new Error(NOT_AN_IMAGE);
+ }
+ if ( typeof file.size === 'number' && file.size > MAX_IMAGE_BYTES ) {
+ throw new Error(IMAGE_TOO_LARGE);
+ }
+ const blob = await file.read();
+ if ( ! (blob instanceof Blob) ) {
+ throw new Error('The image could not be read.');
+ }
+ // The reported size can be stale, so the bytes actually read are checked too.
+ if ( blob.size > MAX_IMAGE_BYTES ) {
+ throw new Error(IMAGE_TOO_LARGE);
+ }
+ const head = String.fromCharCode(...new Uint8Array(await blob.slice(0, 12).arrayBuffer()));
+ if ( (blob.type !== type && ! GENERIC_TYPES.includes(blob.type)) || ! IMAGE_SIGNATURES[type].test(head) ) {
+ throw new Error(NOT_AN_IMAGE);
+ }
+ // A generic Blob type would end up in the data URL, so the image type is set first.
+ return readAsDataUrl(new Blob([blob], { type }));
+}
+```
+
+Call `pickReceiptImage()` from your Add Receipt button's click handler, before
+any other `await`, so the browser allows the picker popup. If it throws, show
+the error and keep the reviews the user already has. The rest of this recipe
+works on the picked image, so this code stops before anything is sent when the
+user cancels. In a click handler, return instead of throwing:
+
+```js
+const receiptImage = await pickReceiptImage();
+if ( receiptImage === null ) {
+ throw new Error('No receipt image was picked.');
+}
+```
+
+To try the whole flow with a receipt image from your Puter account, run the
+first code block in this section in the playground. It sends an image only after
+you pick one, lets you correct and approve the fields, and saves the report CSV
+to your Puter account.
## Read a Receipt
-Your app passes each receipt as one base64 data URL. In a browser,
-[`FileReader.readAsDataURL()`](https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL)
-gives you one. In Node.js, put `data:image/jpeg;base64,` in front of the file's
-base64 contents. The `requestExtraction()` function sends the image with the
-prompt and returns the reply text, or `null` if there's none:
+The `requestExtraction()` function sends one receipt as a base64 data URL with
+the prompt, and returns the reply text, or `null` if there's none. In Node.js,
+put `data:image/jpeg;base64,` in front of the file's base64 contents to get a
+data URL:
-```js;build-an-expense-report-from-receipts
+```js
const FIELDS = ['merchant', 'date', 'currency', 'total'];
const PROMPT = 'Read this receipt. Return the merchant name, the purchase date as YYYY-MM-DD '
+ 'only if the printed date is unambiguous, the currency only if its three-letter code is '
@@ -49,8 +148,7 @@ async function requestExtraction (image) {
}
```
-Send the receipt when the user adds it. Here `receiptImage` is the data URL from
-your app's file picker or camera:
+Send the receipt the user picked:
```js
const receiptReply = await requestExtraction(receiptImage);
@@ -66,9 +164,7 @@ If the AI reads a cafe receipt, leaves out the currency, and suggests the date
The [`outputFormat`](/AI/chat/) schema asks for the four fields as text or
`null`, and [`normalize: true`](/AI/chat/#response-normalization) makes the
reply text a string for every model. The other options are listed on
-[`puter.ai.chat()`](/AI/chat/). To try this request in the playground, run the
-first code block in this section. The playground draws a sample receipt and
-shows the AI's raw reply.
+[`puter.ai.chat()`](/AI/chat/).
## Check the Image
@@ -742,6 +838,45 @@ For the sample expense, `csv` holds this text:
"2026-09-30","Corner Cafe","EUR","12.40"
```
-Save the string or offer it as a download with whatever your platform provides.
-To start a new report after the export, set `REPORT_ID` to a new id the next
+To save the CSV in the user's Puter account, pass it to
+[`puter.ui.showSaveFilePicker()`](/UI/showSaveFilePicker/). It opens the Puter
+save dialog, where the user picks a folder and a file name, and writes the text
+to the file the user chose. When the user closes the dialog in the Puter
+desktop, its promise stays pending, and its `undefinedOnCancel` promise resolves
+to `undefined` instead. In a website, closing the save popup resolves the
+dialog's own promise to an object whose `msg` is `'fileSaveCanceled'`. The
+`saveCsvFile()` function treats both forms as a cancel and resolves to `null`.
+It resolves to the [`FSItem`](/Objects/fsitem/) of the saved file only when the
+result has a file name. It rejects when the save fails or the result doesn't
+name a saved file:
+
+```js
+async function saveCsvFile (csv, suggestedName) {
+ const request = puter.ui.showSaveFilePicker(csv, suggestedName);
+ // A desktop cancel settles undefinedOnCancel, and a website popup cancel resolves with a cancel message.
+ const saved = await Promise.race([request, request.undefinedOnCancel]);
+ if ( saved === undefined || saved?.msg === 'fileSaveCanceled' ) {
+ return null;
+ }
+ // Only a result that names the saved file confirms the save.
+ if ( typeof saved?.name !== 'string' || saved.name === '' ) {
+ throw new Error('The save dialog did not return the saved file, so the save could not be confirmed.');
+ }
+ return saved;
+}
+```
+
+In your Save button's click handler, call `exportCsv()` and then
+`saveCsvFile()` before any other `await`, so the browser allows the dialog
+popup. Tell the user the file is saved only when the call resolves to an item.
+When it resolves to `null`, the user canceled and no file was saved. A rejection
+can come after the file was written, so tell the user the save could not be
+confirmed and to check the destination before retrying. The report in
+[`puter.kv`](/KV/) doesn't change in either case, so the user can save again:
+
+```js
+const savedFile = await saveCsvFile(csv, 'expense-report.csv');
+```
+
+To start a new report after saving the CSV, set `REPORT_ID` to a new id the next
time this code runs, as in [Save the Report](#save-the-report).
diff --git a/src/docs/src/recipes/build-an-expense-report-from-receipts.test.mjs b/src/docs/src/recipes/build-an-expense-report-from-receipts.test.mjs
index b55f21d13..3a6954ee7 100644
--- a/src/docs/src/recipes/build-an-expense-report-from-receipts.test.mjs
+++ b/src/docs/src/recipes/build-an-expense-report-from-receipts.test.mjs
@@ -4,8 +4,9 @@ import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
// Executes the recipe's own JS blocks, in order, so the tests cover exactly the
-// code readers copy. The context has no DOM, and `puter.ai.chat` and `puter.kv`
-// are in-memory mocks. Every receipt is synthetic text, not a real image.
+// code readers copy. The context has no DOM, and `puter.ai.chat`, `puter.kv`,
+// the `puter.ui` pickers, and `FileReader` are in-memory mocks. Every receipt is
+// synthetic bytes behind an image signature, not a real image.
const markdown = readFileSync(new URL('./build-an-expense-report-from-receipts.md', import.meta.url), 'utf8');
const source = [...markdown.matchAll(/^```js(?:;[\w-]+)?\n([\s\S]*?)^```$/gm)].map(([, code]) => code).join('\n');
const companion = readFileSync(new URL('../playground/examples/build-an-expense-report-from-receipts.html', import.meta.url), 'utf8');
@@ -22,7 +23,17 @@ const reply = fields => ({ message: { content: JSON.stringify(fields) } });
const imageOf = text => `data:image/png;base64,${ Buffer.from(text).toString('base64') }`;
const idOf = image => createHash('sha256').update(image).digest('hex');
-const SAMPLE_IMAGE = imageOf('synthetic receipt: corner cafe');
+const SIGNATURES = {
+ 'image/png': Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
+ 'image/jpeg': Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]),
+ 'image/webp': Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x24, 0, 0, 0]), Buffer.from('WEBP')]),
+};
+const imageBytes = (type, text) => Buffer.concat([SIGNATURES[type], Buffer.from(text)]);
+const dataUrlOf = (type, bytes) => `data:${ type };base64,${ bytes.toString('base64') }`;
+const MAX_IMAGE_BYTES = 3_700_000;
+
+const SAMPLE_BYTES = imageBytes('image/png', 'synthetic receipt: corner cafe');
+const SAMPLE_IMAGE = dataUrlOf('image/png', SAMPLE_BYTES);
const OTHER_IMAGE = imageOf('synthetic receipt: city transit');
const THIRD_IMAGE = imageOf('synthetic receipt: hardware store');
const FOURTH_IMAGE = imageOf('synthetic receipt: bakery');
@@ -94,6 +105,68 @@ const persistThenReject = kv => async (key, value) => {
throw new Error('connection reset');
};
+/** Reads a Blob the way the browser's FileReader does, with the Blob's type in the data URL. */
+class FakeFileReader {
+ readAsDataURL (blob) {
+ blob.arrayBuffer().then(buffer => {
+ this.result = dataUrlOf(blob.type || 'application/octet-stream', Buffer.from(buffer));
+ this.onload?.();
+ }, error => {
+ this.error = error;
+ this.onerror?.();
+ });
+ }
+}
+
+/** Picker promises as `puter.ui` returns them, with `undefinedOnCancel` attached. */
+function pickerResolving (value) {
+ const request = Promise.resolve(value);
+ request.undefinedOnCancel = Promise.resolve(value);
+ return request;
+}
+
+function pickerCancelled () {
+ const request = new Promise(() => {});
+ request.undefinedOnCancel = Promise.resolve(undefined);
+ return request;
+}
+
+function pickerRejecting (error) {
+ const request = Promise.reject(error);
+ request.undefinedOnCancel = new Promise(() => {});
+ return request;
+}
+
+/** A website popup cancel, which resolves the picker's own promise with the popup's message. */
+function pickerPopupCancelled (msg) {
+ const request = Promise.resolve({ msg, original_msg_id: 1 });
+ request.undefinedOnCancel = new Promise(() => {});
+ return request;
+}
+
+/** A picked file shaped like an `FSItem`, whose `read()` resolves to a Blob. */
+function fakeFile (name, bytes, { type = 'image/png', size = bytes.length, ...rest } = {}) {
+ return {
+ name,
+ path: `/test-user/Desktop/${ name }`,
+ size,
+ isDir: false,
+ read: vi.fn(async () => new Blob([bytes], { type })),
+ ...rest,
+ };
+}
+
+const savedItem = name => ({ name, path: `/test-user/Desktop/${ name }`, isDir: false });
+
+function fakeUi ({ file = fakeFile('receipt.png', SAMPLE_BYTES) } = {}) {
+ return {
+ showOpenFilePicker: vi.fn(() => pickerResolving(file)),
+ showSaveFilePicker: vi.fn((content, name) => pickerResolving(savedItem(name))),
+ };
+}
+
+const browserGlobals = () => ({ crypto: globalThis.crypto, TextEncoder, Blob, FileReader: FakeFileReader });
+
const REPORT_ID_LINE = `const REPORT_ID = '${ KEY }';`;
/**
@@ -101,34 +174,35 @@ const REPORT_ID_LINE = `const REPORT_ID = '${ KEY }';`;
* steps, and returns its top-level declarations. A `reportId` replaces the
* recipe's `REPORT_ID` value, as a reader configuring another report would.
*/
-async function loadRecipe ({ reportId = KEY } = {}) {
+async function loadRecipe ({
+ reportId = KEY,
+ ui = fakeUi(),
+ sampleChat = vi.fn(async () => reply(SAMPLE_REPLY)),
+ sampleKv = memoryKv(),
+} = {}) {
if ( ! source.includes(REPORT_ID_LINE) ) {
throw new Error(`The recipe no longer declares ${ REPORT_ID_LINE }`);
}
- const sampleChat = vi.fn(async () => reply(SAMPLE_REPLY));
- const sampleKv = memoryKv();
const context = vm.createContext({
- puter: { ai: { chat: sampleChat }, kv: sampleKv },
- crypto: globalThis.crypto,
- TextEncoder,
- receiptImage: SAMPLE_IMAGE,
+ puter: { ai: { chat: sampleChat }, kv: sampleKv, ui },
+ ...browserGlobals(),
});
const configured = source.replace(REPORT_ID_LINE, `const REPORT_ID = ${ JSON.stringify(reportId) };`);
const recipe = await vm.runInContext(
`(async () => {\n${ configured }\nreturn { ${ topLevelNames.join(', ') }, currentDraft: () => draft };\n})()`,
context,
);
- return { recipe, context, sampleChat, sampleKv };
+ return { recipe, context, sampleChat, sampleKv, ui };
}
/** Loads the recipe, then points it at fresh mocks and reopens the report from them. */
-async function setup ({ chat = vi.fn(async () => reply(FULL_REPLY)), stored, kv = memoryKv(stored), open = true, reportId } = {}) {
+async function setup ({ chat = vi.fn(async () => reply(FULL_REPLY)), stored, kv = memoryKv(stored), open = true, reportId, ui = fakeUi() } = {}) {
const { recipe, context } = await loadRecipe({ reportId });
- context.puter = { ai: { chat }, kv };
+ context.puter = { ai: { chat }, kv, ui };
if ( open ) {
await recipe.openDraft();
}
- return { recipe, chat, kv };
+ return { recipe, chat, kv, ui, context };
}
function deferredChat () {
@@ -212,53 +286,527 @@ describe('sample flow', () => {
expect(history.snapshots.map(({ key }) => orderOf(key))).toEqual([3, 2, 1]);
expect(history.snapshots.map(({ report }) => report.revision)).toEqual([3, 2, 1]);
});
-});
-describe('playground companion', () => {
- /** Runs the companion's module script against a minimal DOM whose reply element rejects HTML. */
- async function runCompanion (chat) {
- const output = { textContent: '' };
- Object.defineProperty(output, 'innerHTML', {
- set () {
- throw new Error('the reply must be rendered as text');
- },
- });
- const canvas = { getContext: () => ({ fillRect () {}, fillText () {} }), toDataURL: vi.fn(() => SAMPLE_IMAGE) };
- const document = { createElement: () => canvas, getElementById: () => output, body: { prepend () {} } };
- const context = vm.createContext({ puter: { ai: { chat } }, document });
- await vm.runInContext(`(async () => {\n${ companionScript }\n})()`, context);
- return { output, canvas };
- }
+ it('picks the receipt from Puter before sending it, and saves the CSV through the save dialog', async () => {
+ const { recipe, ui, sampleChat } = await loadRecipe();
- it('sends the drawn receipt with the same prompt and options as the recipe', async () => {
- const { sampleChat } = await loadRecipe();
- const chat = vi.fn(async () => reply(SAMPLE_REPLY));
-
- const { canvas } = await runCompanion(chat);
-
- expect(chat).toHaveBeenCalledTimes(1);
- const [prompt, image, options] = plain(chat.mock.calls[0]);
- const [recipePrompt, , recipeOptions] = plain(sampleChat.mock.calls[0]);
- expect(image).toBe(canvas.toDataURL.mock.results[0].value);
- expect(prompt).toBe(recipePrompt);
- expect(options).toEqual(recipeOptions);
+ expect(plain(ui.showOpenFilePicker.mock.calls)).toEqual([[{ multiple: false, accept: 'image/*' }]]);
+ expect(ui.showOpenFilePicker.mock.invocationCallOrder[0]).toBeLessThan(sampleChat.mock.invocationCallOrder[0]);
+ expect(recipe.receiptImage).toBe(SAMPLE_IMAGE);
+ expect(ui.showSaveFilePicker.mock.calls).toEqual([[SAMPLE_CSV, 'expense-report.csv']]);
+ expect(plain(recipe.savedFile)).toEqual(savedItem('expense-report.csv'));
});
- it('shows the raw reply as text', async () => {
- const content = '
';
- const { output } = await runCompanion(vi.fn(async () => ({ message: { content } })));
- expect(output.textContent).toBe(content);
+ it('stops on cancel without sending, saving, approving, or exporting', async () => {
+ const ui = { ...fakeUi(), showOpenFilePicker: vi.fn(() => pickerCancelled()) };
+ const sampleChat = vi.fn(async () => reply(SAMPLE_REPLY));
+ const sampleKv = memoryKv();
+
+ await expect(loadRecipe({ ui, sampleChat, sampleKv })).rejects.toThrow('No receipt image was picked.');
+ expect(sampleChat).not.toHaveBeenCalled();
+ expect(sampleKv.list).not.toHaveBeenCalled();
+ expect(sampleKv.incr).not.toHaveBeenCalled();
+ expect(sampleKv.set).not.toHaveBeenCalled();
+ expect(ui.showSaveFilePicker).not.toHaveBeenCalled();
+ });
+});
+
+describe('picking a receipt image', () => {
+ it.each([
+ ['receipt.png', 'image/png', 'image/png'],
+ ['receipt.PNG', 'image/png', 'application/octet-stream'],
+ ['receipt.jpg', 'image/jpeg', 'image/jpeg'],
+ ['receipt.JPEG', 'image/jpeg', ''],
+ ['receipt.webp', 'image/webp', 'image/webp'],
+ ['receipt.webp', 'image/webp', 'application/octet-stream'],
+ ])('reads %s as a %s data URL when storage reports %j', async (name, type, reported) => {
+ const bytes = imageBytes(type, `synthetic ${ name }`);
+ const file = fakeFile(name, bytes, { type: reported });
+ const { recipe, chat, kv } = await setup({ ui: fakeUi({ file }), open: false });
+
+ const image = await recipe.pickReceiptImage();
+
+ expect(image).toBe(dataUrlOf(type, bytes));
+ expect(() => recipe.checkImage(image)).not.toThrow();
+ expect(file.read).toHaveBeenCalledTimes(1);
+ expect(chat).not.toHaveBeenCalled();
+ expect(kv.list).not.toHaveBeenCalled();
});
it.each([
- ['an empty response', vi.fn(async () => ({}))],
- ['a rejected request', vi.fn(async () => {
+ ['undefinedOnCancel', () => pickerCancelled()],
+ ['a website popup cancel message', () => pickerPopupCancelled('fileOpenCanceled')],
+ ])('resolves to null on a cancel through %s without reading, sending, or saving anything', async (_, cancel) => {
+ const file = fakeFile('receipt.png', SAMPLE_BYTES);
+ const ui = { ...fakeUi({ file }), showOpenFilePicker: vi.fn(cancel) };
+ const { recipe, chat, kv } = await setup({ ui, open: false });
+
+ expect(await recipe.pickReceiptImage()).toBeNull();
+ expect(file.read).not.toHaveBeenCalled();
+ expect(chat).not.toHaveBeenCalled();
+ expect(kv.list).not.toHaveBeenCalled();
+ expect(kv.set).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ [new Error('The popup was blocked')],
+ ['This API is not compatible in Web Workers.'],
+ ])('rejects when the picker rejects with %j', async (failure) => {
+ const ui = { ...fakeUi(), showOpenFilePicker: vi.fn(() => pickerRejecting(failure)) };
+ const { recipe, chat } = await setup({ ui, open: false });
+
+ await expect(recipe.pickReceiptImage()).rejects.toBe(failure);
+ expect(chat).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['two files', () => [fakeFile('a.png', SAMPLE_BYTES), fakeFile('b.png', SAMPLE_BYTES)]],
+ ['a list with one file', () => [fakeFile('a.png', SAMPLE_BYTES)]],
+ ['a directory', () => fakeFile('receipts.png', SAMPLE_BYTES, { isDir: true })],
+ ['an item without read()', () => ({ name: 'a.png', isDir: false })],
+ ['a GIF', () => fakeFile('receipt.gif', SAMPLE_BYTES, { type: 'image/gif' })],
+ ['an SVG', () => fakeFile('receipt.svg', SAMPLE_BYTES, { type: 'image/svg+xml' })],
+ ['a PDF', () => fakeFile('receipt.pdf', SAMPLE_BYTES, { type: 'application/pdf' })],
+ ['a name without an extension', () => fakeFile('png', SAMPLE_BYTES)],
+ ['an extension that is an object key', () => fakeFile('receipt.constructor', SAMPLE_BYTES)],
+ ['an item without a name', () => fakeFile(undefined, SAMPLE_BYTES)],
+ ['null', () => null],
+ ['a save cancel message', () => ({ msg: 'fileSaveCanceled', original_msg_id: 1 })],
+ ['another popup message', () => ({ msg: 'fileOpened', original_msg_id: 1 })],
+ ])('rejects %s without reading it', async (_, makePick) => {
+ const picked = makePick();
+ const { recipe, chat } = await setup({ ui: fakeUi({ file: picked }), open: false });
+
+ await expect(recipe.pickReceiptImage()).rejects.toThrow('Pick one PNG, JPEG, or WebP image.');
+ for ( const item of [picked].flat() ) {
+ if ( item?.read ) {
+ expect(item.read).not.toHaveBeenCalled();
+ }
+ }
+ expect(chat).not.toHaveBeenCalled();
+ });
+
+ it('rejects an image over the cap by its reported size without reading it', async () => {
+ const file = fakeFile('receipt.png', SAMPLE_BYTES, { size: MAX_IMAGE_BYTES + 1 });
+ const { recipe } = await setup({ ui: fakeUi({ file }), open: false });
+
+ await expect(recipe.pickReceiptImage()).rejects.toThrow(/at most 3,700,000 bytes/);
+ expect(file.read).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['a small reported size', 10],
+ ['no reported size', null],
+ ])('rejects read bytes over the cap with %s', async (_, size) => {
+ const bytes = Buffer.concat([SIGNATURES['image/png'], Buffer.alloc(MAX_IMAGE_BYTES)]);
+ const file = fakeFile('receipt.png', bytes, { size });
+ const { recipe } = await setup({ ui: fakeUi({ file }), open: false });
+
+ await expect(recipe.pickReceiptImage()).rejects.toThrow(/at most 3,700,000 bytes/);
+ expect(file.read).toHaveBeenCalledTimes(1);
+ });
+
+ it('accepts an image at the byte cap, and its data URL passes checkImage()', async () => {
+ const bytes = Buffer.concat([SIGNATURES['image/png'], Buffer.alloc(MAX_IMAGE_BYTES - 8, 1)]);
+ const { recipe } = await setup({ ui: fakeUi({ file: fakeFile('receipt.png', bytes) }), open: false });
+
+ const image = await recipe.pickReceiptImage();
+
+ expect(image.length).toBeLessThanOrEqual(5_000_000);
+ expect(() => recipe.checkImage(image)).not.toThrow();
+ });
+
+ it.each([
+ ['a different image type', 'receipt.png', SAMPLE_BYTES, 'image/gif'],
+ ['a JPEG type for a PNG name', 'receipt.png', SAMPLE_BYTES, 'image/jpeg'],
+ ['an HTML type', 'receipt.png', SAMPLE_BYTES, 'text/html'],
+ ['JPEG bytes behind a PNG name', 'receipt.png', imageBytes('image/jpeg', 'synthetic'), 'application/octet-stream'],
+ ['text behind a PNG name', 'receipt.png', Buffer.from(''), ''],
+ ['a RIFF file that is not WebP', 'receipt.webp', Buffer.from('RIFF\x24\x00\x00\x00WAVEfmt '), 'image/webp'],
+ ['an empty file', 'receipt.jpg', Buffer.alloc(0), 'image/jpeg'],
+ ])('rejects %s', async (_, name, bytes, type) => {
+ const { recipe, chat } = await setup({ ui: fakeUi({ file: fakeFile(name, bytes, { type }) }), open: false });
+
+ await expect(recipe.pickReceiptImage()).rejects.toThrow('Pick one PNG, JPEG, or WebP image.');
+ expect(chat).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['text instead of a Blob', async () => SAMPLE_IMAGE, /could not be read/],
+ ['nothing', async () => undefined, /could not be read/],
+ ['a rejection', async () => {
throw new Error('offline');
- })],
- ])('shows a text message for %s', async (_, chat) => {
- const { output } = await runCompanion(chat);
- expect(typeof output.textContent).toBe('string');
- expect(output.textContent).not.toBe('');
+ }, /offline/],
+ ])('rejects when read() gives %s', async (_, read, message) => {
+ const file = { ...fakeFile('receipt.png', SAMPLE_BYTES), read: vi.fn(read) };
+ const { recipe } = await setup({ ui: fakeUi({ file }), open: false });
+
+ await expect(recipe.pickReceiptImage()).rejects.toThrow(message);
+ });
+
+ it('rejects when the FileReader fails', async () => {
+ const { recipe, context } = await setup({ open: false });
+ context.FileReader = class {
+ readAsDataURL () {
+ this.error = new Error('The file could not be decoded');
+ queueMicrotask(() => this.onerror());
+ }
+ };
+
+ await expect(recipe.pickReceiptImage()).rejects.toThrow('The file could not be decoded');
+ });
+
+ it('feeds the picked image into the review and approval flow', async () => {
+ const { recipe, chat, kv } = await setup({ chat: vi.fn(async () => reply(SAMPLE_REPLY)) });
+
+ const image = await recipe.pickReceiptImage();
+ const review = await recipe.extractReceipt(image);
+
+ expect(chat).toHaveBeenCalledTimes(1);
+ expect(chat.mock.calls[0][1]).toBe(SAMPLE_IMAGE);
+ expect(review.receiptId).toBe(idOf(SAMPLE_IMAGE));
+ expect(latest(kv).expenses).toEqual([]);
+ await recipe.approveExpense(review.receiptId, VALID);
+ expect(recipe.exportCsv()).toBe(SAMPLE_CSV);
+ });
+});
+
+describe('saving the CSV file', () => {
+ it.each([
+ ['undefinedOnCancel', () => pickerCancelled()],
+ ['a website popup cancel message', () => pickerPopupCancelled('fileSaveCanceled')],
+ ])('resolves to null when the user cancels through %s, and leaves the report unchanged', async (_, cancel) => {
+ const ui = { ...fakeUi(), showSaveFilePicker: vi.fn(cancel) };
+ const { recipe, kv } = await setup({ ui, stored: storedReport({ expenses: [expenseRow(SAMPLE_IMAGE)] }) });
+ const before = new Map(kv.store);
+
+ expect(await recipe.saveCsvFile(recipe.exportCsv(), 'expense-report.csv')).toBeNull();
+ expect(ui.showSaveFilePicker).toHaveBeenCalledWith(SAMPLE_CSV, 'expense-report.csv');
+ expect(kv.store).toEqual(before);
+ expect(recipe.currentDraft()).not.toBeNull();
+ });
+
+ it.each([
+ ['an empty object', {}],
+ ['an item without a name', { path: '/test-user/Desktop/expense-report.csv', isDir: false }],
+ ['an item with an empty name', savedItem('')],
+ ['a non-string name', { name: 42 }],
+ ['an open cancel message', { msg: 'fileOpenCanceled', original_msg_id: 1 }],
+ ['another popup message', { msg: 'fileSaved', original_msg_id: 1 }],
+ ['null', null],
+ ['a string', 'expense-report.csv'],
+ ])('rejects %s as an unconfirmed save, and leaves the report unchanged', async (_, value) => {
+ const ui = { ...fakeUi(), showSaveFilePicker: vi.fn(() => pickerResolving(value)) };
+ const { recipe, kv } = await setup({ ui, stored: storedReport({ expenses: [expenseRow(SAMPLE_IMAGE)] }) });
+ const before = new Map(kv.store);
+
+ await expect(recipe.saveCsvFile(recipe.exportCsv(), 'expense-report.csv')).rejects.toThrow(/could not be confirmed/);
+ expect(kv.store).toEqual(before);
+ expect(recipe.exportCsv()).toBe(SAMPLE_CSV);
+ });
+
+ it.each([
+ [new Error('Storage is full')],
+ ['This API is not compatible in Web Workers.'],
+ ])('rejects when the save rejects with %j, and the report can still be exported', async (failure) => {
+ const ui = { ...fakeUi(), showSaveFilePicker: vi.fn(() => pickerRejecting(failure)) };
+ const { recipe, kv } = await setup({ ui, stored: storedReport({ expenses: [expenseRow(SAMPLE_IMAGE)] }) });
+ const before = new Map(kv.store);
+
+ await expect(recipe.saveCsvFile(recipe.exportCsv(), 'expense-report.csv')).rejects.toBe(failure);
+ expect(kv.store).toEqual(before);
+ expect(recipe.exportCsv()).toBe(SAMPLE_CSV);
+ });
+});
+
+// -- Playground companion --
+
+/** A DOM element stub that renders text only and records listeners. */
+function fakeElement (tag) {
+ let text = '';
+ return {
+ tag,
+ children: [],
+ listeners: {},
+ value: '',
+ disabled: false,
+ hidden: false,
+ get textContent () {
+ return text + this.children.map(child => child.textContent).join('');
+ },
+ set textContent (value) {
+ text = String(value);
+ this.children = [];
+ },
+ set innerHTML (_) {
+ throw new Error('The companion must render text, not HTML.');
+ },
+ append (...nodes) {
+ this.children.push(...nodes);
+ },
+ replaceChildren (...nodes) {
+ this.children = [...nodes];
+ },
+ addEventListener (type, listener) {
+ (this.listeners[type] ??= []).push(listener);
+ },
+ };
+}
+
+const fire = (element, type) => Promise.all((element.listeners[type] ?? []).map(listener => listener({ type })));
+
+describe('playground companion', () => {
+ /** Runs the companion's module script against the stub DOM, then drives it like a user. */
+ async function runCompanion ({ chat = vi.fn(async () => reply(SAMPLE_REPLY)), kv = memoryKv(), ui = fakeUi() } = {}) {
+ const elements = new Map();
+ const byId = id => {
+ if ( ! elements.has(id) ) {
+ elements.set(id, fakeElement(`#${ id }`));
+ }
+ return elements.get(id);
+ };
+ const document = { getElementById: byId, createElement: tag => fakeElement(tag) };
+ const context = vm.createContext({ puter: { ai: { chat }, kv, ui }, document, ...browserGlobals() });
+ await vm.runInContext(`(async () => {\n${ companionScript }\n})()`, context);
+ return {
+ chat,
+ kv,
+ ui,
+ byId,
+ click: id => fire(byId(id), 'click'),
+ status: () => byId('status').textContent,
+ problems: () => byId('problems').children.map(item => item.textContent),
+ fields: () => Object.fromEntries(['merchant', 'date', 'currency', 'total'].map(name => [name, byId(name).value])),
+ type: values => Object.entries(values).forEach(([name, value]) => {
+ byId(name).value = value;
+ }),
+ };
+ }
+
+ const kvCalls = kv => kv.list.mock.calls.length + kv.get.mock.calls.length + kv.set.mock.calls.length + kv.incr.mock.calls.length;
+
+ it('makes no Puter call until the user picks a receipt', async () => {
+ const page = await runCompanion();
+
+ expect(page.ui.showOpenFilePicker).not.toHaveBeenCalled();
+ expect(page.ui.showSaveFilePicker).not.toHaveBeenCalled();
+ expect(page.chat).not.toHaveBeenCalled();
+ expect(kvCalls(page.kv)).toBe(0);
+ expect(page.byId('pick').disabled).toBe(false);
+ expect(page.byId('approve').disabled).toBe(true);
+ expect(page.byId('save').disabled).toBe(true);
+ });
+
+ it('sends and saves nothing when the user cancels the picker', async () => {
+ const ui = { ...fakeUi(), showOpenFilePicker: vi.fn(() => pickerCancelled()) };
+ const page = await runCompanion({ ui });
+
+ await page.click('pick');
+
+ expect(page.status()).toMatch(/No image was picked, so nothing was sent/);
+ expect(page.chat).not.toHaveBeenCalled();
+ expect(kvCalls(page.kv)).toBe(0);
+ });
+
+ it.each([
+ ['a GIF', fakeFile('receipt.gif', SAMPLE_BYTES, { type: 'image/gif' }), /PNG, JPEG, or WebP/],
+ ['an oversize image', fakeFile('receipt.png', SAMPLE_BYTES, { size: MAX_IMAGE_BYTES + 1 }), /3,700,000 bytes/],
+ ])('rejects %s without sending it or opening the report', async (_, file, message) => {
+ const page = await runCompanion({ ui: fakeUi({ file }) });
+
+ await page.click('pick');
+
+ expect(page.status()).toMatch(message);
+ expect(page.status()).toMatch(/Nothing was sent/);
+ expect(page.chat).not.toHaveBeenCalled();
+ expect(kvCalls(page.kv)).toBe(0);
+ });
+
+ it('reads the picked receipt with the recipe request, and presets only checked fields for review', async () => {
+ const { sampleChat } = await loadRecipe();
+ const page = await runCompanion();
+
+ await page.click('pick');
+
+ expect(plain(page.ui.showOpenFilePicker.mock.calls)).toEqual([[{ multiple: false, accept: 'image/*' }]]);
+ expect(page.chat).toHaveBeenCalledTimes(1);
+ expect(plain(page.chat.mock.calls[0])).toEqual(plain(sampleChat.mock.calls[0]));
+ expect(page.fields()).toEqual({ merchant: 'Corner Cafe', date: '', currency: '', total: '12.40' });
+ expect(page.problems()).toEqual([
+ 'Ignored the suggested date. It must be a real date written as YYYY-MM-DD.',
+ 'Fill in from the receipt: date, currency.',
+ ]);
+ expect(page.byId('preview').src).toBe(SAMPLE_IMAGE);
+ expect(page.byId('preview').hidden).toBe(false);
+ expect(latest(page.kv).expenses).toEqual([]);
+ expect(latest(page.kv).reviews.map(review => review.receiptId)).toEqual([idOf(SAMPLE_IMAGE)]);
+ expect(page.byId('approve').disabled).toBe(false);
+ expect(page.byId('save').disabled).toBe(true);
+ });
+
+ it('approves only the checked values the user confirms, then saves the approved CSV', async () => {
+ const page = await runCompanion();
+ await page.click('pick');
+
+ await page.click('approve');
+ expect(page.status()).toMatch(/The date must be/);
+ expect(page.status()).toMatch(/The currency must be/);
+ expect(page.kv.set).toHaveBeenCalledTimes(1);
+ expect(latest(page.kv).expenses).toEqual([]);
+
+ page.type({ date: '2026-09-30', currency: 'EUR' });
+ await page.click('approve');
+
+ expect(latest(page.kv).expenses).toEqual([expenseRow(SAMPLE_IMAGE)]);
+ expect(latest(page.kv).reviews).toEqual([]);
+ expect(page.fields()).toEqual({ merchant: '', date: '', currency: '', total: '' });
+ expect(page.byId('approve').disabled).toBe(true);
+ expect(page.byId('save').disabled).toBe(false);
+ expect(page.ui.showSaveFilePicker).not.toHaveBeenCalled();
+
+ await page.click('save');
+
+ expect(page.ui.showSaveFilePicker.mock.calls).toEqual([[SAMPLE_CSV, 'expense-report.csv']]);
+ expect(page.status()).toMatch(/Saved the report as expense-report\.csv/);
+ expect(page.chat).toHaveBeenCalledTimes(1);
+ });
+
+ it('reports a cancelled or failed CSV save without claiming success, and saves on retry', async () => {
+ const ui = fakeUi();
+ const page = await runCompanion({ ui });
+ await page.click('pick');
+ page.type({ date: '2026-09-30', currency: 'EUR' });
+ await page.click('approve');
+
+ ui.showSaveFilePicker.mockImplementationOnce(() => pickerCancelled());
+ await page.click('save');
+ expect(page.status()).toMatch(/Save cancelled\. No CSV file was saved\./);
+ expect(page.byId('save').disabled).toBe(false);
+
+ ui.showSaveFilePicker.mockImplementationOnce(() => pickerRejecting(new Error('Storage is full')));
+ await page.click('save');
+ expect(page.status()).toBe('Save could not be confirmed (Storage is full). Check the destination before retrying.');
+ expect(page.status()).not.toMatch(/Saved the report/);
+ expect(latest(page.kv).expenses).toEqual([expenseRow(SAMPLE_IMAGE)]);
+ expect(page.byId('save').disabled).toBe(false);
+
+ await page.click('save');
+ expect(page.status()).toMatch(/Saved the report as expense-report\.csv/);
+ expect(ui.showSaveFilePicker.mock.calls.map(([csv]) => csv)).toEqual([SAMPLE_CSV, SAMPLE_CSV, SAMPLE_CSV]);
+ });
+
+ it('sends and saves nothing when the user cancels the website popup picker', async () => {
+ const ui = { ...fakeUi(), showOpenFilePicker: vi.fn(() => pickerPopupCancelled('fileOpenCanceled')) };
+ const page = await runCompanion({ ui });
+
+ await page.click('pick');
+
+ expect(page.status()).toBe('No image was picked, so nothing was sent.');
+ expect(page.chat).not.toHaveBeenCalled();
+ expect(kvCalls(page.kv)).toBe(0);
+ expect(page.byId('approve').disabled).toBe(true);
+ });
+
+ it('reports a website popup save cancel as cancelled and an unexpected save result as unconfirmed', async () => {
+ const ui = fakeUi();
+ const page = await runCompanion({ ui });
+ await page.click('pick');
+ page.type({ date: '2026-09-30', currency: 'EUR' });
+ await page.click('approve');
+
+ ui.showSaveFilePicker.mockImplementationOnce(() => pickerPopupCancelled('fileSaveCanceled'));
+ await page.click('save');
+ expect(page.status()).toBe('Save cancelled. No CSV file was saved.');
+ expect(page.byId('save').disabled).toBe(false);
+
+ ui.showSaveFilePicker.mockImplementationOnce(() => pickerResolving({}));
+ await page.click('save');
+ expect(page.status()).toMatch(/^Save could not be confirmed \(.*could not be confirmed\.\)\. Check the destination before retrying\.$/);
+ expect(page.status()).not.toMatch(/Saved the report|No CSV file was saved/);
+ expect(latest(page.kv).expenses).toEqual([expenseRow(SAMPLE_IMAGE)]);
+ expect(page.byId('save').disabled).toBe(false);
+
+ await page.click('save');
+ expect(page.status()).toMatch(/Saved the report as expense-report\.csv/);
+ });
+
+ it('requires reopening the report after an unconfirmed approval, then approves once', async () => {
+ const page = await runCompanion();
+ await page.click('pick');
+ page.type({ date: '2026-09-30', currency: 'EUR' });
+ page.kv.set.mockResolvedValueOnce(false);
+
+ await page.click('approve');
+ expect(page.status()).toMatch(/was not confirmed/);
+ expect(page.status()).toMatch(/Use Open report/);
+ expect(page.byId('reopen').disabled).toBe(false);
+ expect(page.byId('save').disabled).toBe(true);
+
+ const sets = page.kv.set.mock.calls.length;
+ await page.click('approve');
+ expect(page.status()).toMatch(/Open the report again/);
+ expect(page.kv.set).toHaveBeenCalledTimes(sets);
+
+ await page.click('reopen');
+ expect(page.status()).toBe('The report is open.');
+ expect(page.byId('reopen').disabled).toBe(true);
+ expect(page.fields()).toEqual(VALID);
+ await page.click('approve');
+
+ expect(latest(page.kv).expenses).toEqual([expenseRow(SAMPLE_IMAGE)]);
+ expect(page.chat).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps a review whose save failed, and approves it after reopening', async () => {
+ const page = await runCompanion();
+ page.kv.set.mockRejectedValueOnce(new Error('offline'));
+
+ await page.click('pick');
+
+ expect(page.status()).toMatch(/The review may not be saved\. The save was not confirmed \(offline\)/);
+ expect(page.fields()).toEqual({ merchant: 'Corner Cafe', date: '', currency: '', total: '12.40' });
+ await page.click('reopen');
+ page.type({ date: '2026-09-30', currency: 'EUR' });
+ await page.click('approve');
+
+ expect(latest(page.kv).expenses).toEqual([expenseRow(SAMPLE_IMAGE)]);
+ expect(page.chat).toHaveBeenCalledTimes(1);
+ });
+
+ it('starts with every field empty when the AI request fails', async () => {
+ const page = await runCompanion({ chat: vi.fn(async () => {
+ throw new Error('offline');
+ }) });
+
+ await page.click('pick');
+
+ expect(page.fields()).toEqual({ merchant: '', date: '', currency: '', total: '' });
+ expect(page.problems()[0]).toBe('No AI suggestions (offline). Fill in each field from the receipt.');
+ expect(latest(page.kv).expenses).toEqual([]);
+ });
+
+ it('shows AI text that looks like markup only as input values and text', async () => {
+ const markup = '
';
+ const page = await runCompanion({ chat: vi.fn(async () => reply({ ...SAMPLE_REPLY, merchant: markup, currency: markup })) });
+
+ await page.click('pick');
+
+ expect(page.fields().merchant).toBe(markup);
+ expect(page.fields().currency).toBe('');
+ expect(page.problems()).toContain('Ignored the suggested currency. It must be a three-letter code in capitals, such as EUR.');
+ expect(globalThis.pwned).toBeUndefined();
+ });
+
+ it('shows an error when the report cannot be opened, without sending the image', async () => {
+ const kv = memoryKv(storedReport({ version: 2 }));
+ const page = await runCompanion({ kv });
+
+ await page.click('pick');
+
+ expect(page.status()).toMatch(/version 1/);
+ expect(page.chat).not.toHaveBeenCalled();
+ expect(page.kv.set).not.toHaveBeenCalled();
+ expect(page.byId('reopen').disabled).toBe(false);
});
});