mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-20 10:32:36 +00:00
Add Puter receipt file selection and report export
This commit is contained in:
1 parent
b15cfe15b4
commit
02596cd0fe
3 files changed
+1362
-93
No files matched your search
@@ -1,21 +1,80 @@
|
||||
<html>
|
||||
<body>
|
||||
<p>
|
||||
<button id="pick">Pick a receipt image from Puter</button>
|
||||
<button id="reopen">Open report</button>
|
||||
</p>
|
||||
<p id="status">Pick a PNG, JPEG, or WebP receipt image. Nothing is sent until you pick one.</p>
|
||||
<img id="preview" alt="The picked receipt" width="320" hidden>
|
||||
<ul id="problems"></ul>
|
||||
<p>
|
||||
<label>Merchant <input id="merchant"></label>
|
||||
<label>Date <input id="date" placeholder="YYYY-MM-DD"></label>
|
||||
<label>Currency <input id="currency" placeholder="EUR"></label>
|
||||
<label>Total <input id="total" placeholder="12.40"></label>
|
||||
</p>
|
||||
<p>
|
||||
<button id="approve">Approve expense</button>
|
||||
<button id="save">Save report CSV to Puter</button>
|
||||
</p>
|
||||
<p id="report"></p>
|
||||
<p>Saving corrections for later, discarding reviews, and recovering earlier saves are in the recipe.</p>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<pre id="reply">Reading the sample receipt...</pre>
|
||||
<script type="module">
|
||||
// Draw a small synthetic receipt, so no real receipt is sent.
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 320;
|
||||
canvas.height = 200;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = '18px monospace';
|
||||
['Corner Cafe', '2026-09-30', 'Coffee 3.40', 'Sandwich 9.00', 'Total EUR 12.40']
|
||||
.forEach((line, i) => ctx.fillText(line, 20, 36 + i * 34));
|
||||
document.body.prepend(canvas);
|
||||
const receiptImage = canvas.toDataURL('image/png');
|
||||
// -- Recipe code, unchanged --
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
||||
const FIELDS = ['merchant', 'date', 'currency', 'total'];
|
||||
const PROMPT = 'Read this receipt. Return the merchant name, the purchase date as YYYY-MM-DD '
|
||||
@@ -42,13 +101,540 @@
|
||||
return response?.message?.content ?? null;
|
||||
}
|
||||
|
||||
// The reply comes from the AI, so it's shown as plain text.
|
||||
const output = document.getElementById('reply');
|
||||
try {
|
||||
output.textContent = await requestExtraction(receiptImage) ?? 'The AI returned no reply text.';
|
||||
} catch ( error ) {
|
||||
output.textContent = `The request failed (${ error?.message ?? error?.error?.message ?? 'unknown error' }).`;
|
||||
const IMAGE_PREFIX = /^data:image\/(?:jpeg|png|webp);base64,/;
|
||||
const NOT_BASE64 = /[^A-Za-z0-9+/]/;
|
||||
const MAX_IMAGE_LENGTH = 5_000_000;
|
||||
|
||||
// Plain length and character checks, since one regex over a multi-megabyte body can overflow the regex stack.
|
||||
function checkImage (image) {
|
||||
const prefix = typeof image === 'string' && image.length <= MAX_IMAGE_LENGTH ? IMAGE_PREFIX.exec(image) : null;
|
||||
const body = prefix ? image.slice(prefix[0].length) : '';
|
||||
const padding = body.endsWith('==') ? 2 : body.endsWith('=') ? 1 : 0;
|
||||
if ( body.length === 0 || body.length % 4 !== 0 || NOT_BASE64.test(body.slice(0, body.length - padding)) ) {
|
||||
throw new Error('Pass one JPEG, PNG, or WebP receipt as a base64 data URL of at most 5,000,000 characters.');
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_REPLY_LENGTH = 2_000;
|
||||
const MAX_MERCHANT_LENGTH = 100;
|
||||
|
||||
const RULES = {
|
||||
merchant: `text of 1 to ${ MAX_MERCHANT_LENGTH } characters on one line`,
|
||||
date: 'a real date written as YYYY-MM-DD',
|
||||
currency: 'a three-letter code in capitals, such as EUR',
|
||||
total: 'digits with an optional dot and up to 3 decimals, such as 12.40',
|
||||
};
|
||||
|
||||
const CHECKS = {
|
||||
merchant: value => value.trim() !== '' && value.length <= MAX_MERCHANT_LENGTH && ! /[\x00-\x1F\x7F]/.test(value),
|
||||
date: isCalendarDate,
|
||||
currency: value => /^[A-Z]{3}$/.test(value),
|
||||
total: value => /^\d{1,12}(\.\d{1,3})?$/.test(value),
|
||||
};
|
||||
|
||||
function isCalendarDate (value) {
|
||||
if ( ! /^\d{4}-\d{2}-\d{2}$/.test(value) ) {
|
||||
return false;
|
||||
}
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||
}
|
||||
|
||||
function isValid (name, value) {
|
||||
return typeof value === 'string' && CHECKS[name](value);
|
||||
}
|
||||
|
||||
function isRecord (value) {
|
||||
return typeof value === 'object' && value !== null && ! Array.isArray(value);
|
||||
}
|
||||
|
||||
function emptyFields () {
|
||||
return Object.fromEntries(FIELDS.map(name => [name, null]));
|
||||
}
|
||||
|
||||
function parseExtraction (content) {
|
||||
const fields = emptyFields();
|
||||
const problems = [];
|
||||
|
||||
let reply;
|
||||
if ( typeof content === 'string' && content.length <= MAX_REPLY_LENGTH ) {
|
||||
try {
|
||||
reply = JSON.parse(content);
|
||||
} catch {
|
||||
reply = undefined;
|
||||
}
|
||||
}
|
||||
if ( ! isRecord(reply) ) {
|
||||
problems.push('The AI reply was too long or not a receipt, so no field was filled in.');
|
||||
return { fields, problems };
|
||||
}
|
||||
|
||||
for ( const name of FIELDS ) {
|
||||
const value = reply[name] ?? null;
|
||||
if ( value === null ) {
|
||||
continue;
|
||||
}
|
||||
if ( isValid(name, value) ) {
|
||||
fields[name] = value;
|
||||
} else {
|
||||
problems.push(`Ignored the suggested ${ name }. It must be ${ RULES[name] }.`);
|
||||
}
|
||||
}
|
||||
return { fields, problems };
|
||||
}
|
||||
|
||||
const REPORT_ID = 'expenseReport';
|
||||
const SNAPSHOT_PREFIX = `${ REPORT_ID }:snapshot:`;
|
||||
const COUNTER_KEY = `${ REPORT_ID }:order`;
|
||||
// Wide enough for Number.MAX_SAFE_INTEGER, so snapshot keys sort in save-number order.
|
||||
const ORDER_WIDTH = 16;
|
||||
const SNAPSHOT_NAME = /^\d{16}:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const DRAFT_VERSION = 1;
|
||||
const MAX_EXPENSES = 300;
|
||||
const MAX_REVIEWS = 20;
|
||||
const MAX_PROBLEMS = 5;
|
||||
const MAX_PROBLEM_LENGTH = 300;
|
||||
const RECEIPT_ID = /^[0-9a-f]{64}$/;
|
||||
const DAMAGED = 'The saved report is damaged, so it was left unchanged.';
|
||||
|
||||
let draft = null;
|
||||
let draftKey = null;
|
||||
let saving = Promise.resolve();
|
||||
|
||||
function copy (value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function isReceiptId (value) {
|
||||
return typeof value === 'string' && RECEIPT_ID.test(value);
|
||||
}
|
||||
|
||||
function toReview (receiptId, fields, problems) {
|
||||
return { receiptId, fields, unresolved: FIELDS.filter(name => fields[name] === null), problems };
|
||||
}
|
||||
|
||||
function readExpense (item) {
|
||||
if ( ! isRecord(item) || ! isReceiptId(item.receiptId) || ! FIELDS.every(name => isValid(name, item[name])) ) {
|
||||
return null;
|
||||
}
|
||||
return { receiptId: item.receiptId, ...Object.fromEntries(FIELDS.map(name => [name, item[name]])) };
|
||||
}
|
||||
|
||||
function readReview (item) {
|
||||
if ( ! isRecord(item) || ! isReceiptId(item.receiptId) || ! isRecord(item.fields)
|
||||
|| ! Array.isArray(item.problems) || item.problems.length > MAX_PROBLEMS ) {
|
||||
return null;
|
||||
}
|
||||
const fields = Object.fromEntries(FIELDS.map(name => [name, item.fields[name]]));
|
||||
const fieldsOk = FIELDS.every(name => fields[name] === null || isValid(name, fields[name]));
|
||||
const problemsOk = item.problems.every(problem => typeof problem === 'string' && problem.length <= MAX_PROBLEM_LENGTH);
|
||||
return fieldsOk && problemsOk ? toReview(item.receiptId, fields, [...item.problems]) : null;
|
||||
}
|
||||
|
||||
function parseDraft (value) {
|
||||
if ( value === null ) {
|
||||
return { version: DRAFT_VERSION, revision: 0, expenses: [], reviews: [] };
|
||||
}
|
||||
if ( ! isRecord(value) || value.version !== DRAFT_VERSION ) {
|
||||
throw new Error(`The saved report isn't a version ${ DRAFT_VERSION } report, so it was left unchanged.`);
|
||||
}
|
||||
const { revision, expenses, reviews } = value;
|
||||
if ( ! Number.isSafeInteger(revision) || revision < 1
|
||||
|| ! Array.isArray(expenses) || expenses.length > MAX_EXPENSES
|
||||
|| ! Array.isArray(reviews) || reviews.length > MAX_REVIEWS ) {
|
||||
throw new Error(DAMAGED);
|
||||
}
|
||||
const parsed = {
|
||||
version: DRAFT_VERSION,
|
||||
revision,
|
||||
expenses: expenses.map(readExpense),
|
||||
reviews: reviews.map(readReview),
|
||||
};
|
||||
const items = [...parsed.expenses, ...parsed.reviews];
|
||||
if ( items.includes(null) || new Set(items.map(item => item.receiptId)).size !== items.length ) {
|
||||
throw new Error(DAMAGED);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requireOpen () {
|
||||
if ( draft === null ) {
|
||||
throw new Error('Open the report with openDraft() first.');
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotKey (order) {
|
||||
return `${ SNAPSHOT_PREFIX }${ String(order).padStart(ORDER_WIDTH, '0') }:${ crypto.randomUUID() }`;
|
||||
}
|
||||
|
||||
function readSnapshot (item) {
|
||||
if ( ! SNAPSHOT_NAME.test(item.key.slice(SNAPSHOT_PREFIX.length)) || item.value === null ) {
|
||||
throw new Error(DAMAGED);
|
||||
}
|
||||
return parseDraft(item.value);
|
||||
}
|
||||
|
||||
function isSavedAs (value, expected) {
|
||||
try {
|
||||
return JSON.stringify(parseDraft(value)) === expected;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readLatest () {
|
||||
const page = await puter.kv.list({
|
||||
pattern: SNAPSHOT_PREFIX,
|
||||
reverse: true,
|
||||
limit: 1,
|
||||
fetchUntilFull: true,
|
||||
returnValues: true,
|
||||
});
|
||||
if ( page.items.length === 0 ) {
|
||||
return { key: null, report: parseDraft(null) };
|
||||
}
|
||||
return { key: page.items[0].key, report: readSnapshot(page.items[0]) };
|
||||
}
|
||||
|
||||
function queue (task) {
|
||||
const run = saving.then(task);
|
||||
saving = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
function openDraft () {
|
||||
return queue(async () => {
|
||||
const latest = await readLatest();
|
||||
draft = latest.report;
|
||||
draftKey = latest.key;
|
||||
return copy(draft);
|
||||
});
|
||||
}
|
||||
|
||||
function commit (change) {
|
||||
return queue(async () => {
|
||||
requireOpen();
|
||||
const next = copy(draft);
|
||||
change(next);
|
||||
const stored = await readLatest();
|
||||
if ( stored.key !== draftKey || JSON.stringify(stored.report) !== JSON.stringify(draft) ) {
|
||||
throw new Error('The report changed in another tab or device. Open it again before making changes.');
|
||||
}
|
||||
if ( draft.revision >= Number.MAX_SAFE_INTEGER ) {
|
||||
throw new Error('The report has been saved too many times. Export it and start a new report.');
|
||||
}
|
||||
next.revision = draft.revision + 1;
|
||||
const checked = parseDraft(next);
|
||||
const expected = JSON.stringify(checked);
|
||||
|
||||
// The save number is reserved before the write, so a write that lands late can't rank above a later save.
|
||||
let order;
|
||||
try {
|
||||
order = await puter.kv.incr(COUNTER_KEY);
|
||||
} catch ( error ) {
|
||||
throw new Error(`Nothing was saved (${ String(error?.message ?? 'the save could not start') }). Try again.`);
|
||||
}
|
||||
if ( ! Number.isSafeInteger(order) || order < 1 || order >= Number.MAX_SAFE_INTEGER ) {
|
||||
throw new Error('The report\'s save counter is damaged, so nothing was saved. Start a new report.');
|
||||
}
|
||||
const key = snapshotKey(order);
|
||||
|
||||
let failure = null;
|
||||
try {
|
||||
if ( await puter.kv.set(key, checked) !== true ) {
|
||||
failure = 'the save did not return true';
|
||||
}
|
||||
} catch ( error ) {
|
||||
failure = String(error?.message ?? 'the save failed');
|
||||
}
|
||||
if ( failure !== null ) {
|
||||
// A failed response doesn't prove nothing was stored, so read this save's own key back once.
|
||||
const readBack = await puter.kv.get(key).catch(() => undefined);
|
||||
if ( ! isSavedAs(readBack, expected) ) {
|
||||
const found = readBack === null
|
||||
? `The save was not confirmed (${ failure }), and reading it back found nothing yet.`
|
||||
: `The report may or may not have been saved (${ failure }).`;
|
||||
draft = null;
|
||||
throw new Error(`${ found } Open it again with openDraft() before making changes.`);
|
||||
}
|
||||
}
|
||||
draft = checked;
|
||||
draftKey = key;
|
||||
});
|
||||
}
|
||||
|
||||
const busy = new Set();
|
||||
let extracting = 0;
|
||||
|
||||
async function receiptIdOf (image) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(image));
|
||||
return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function lock (receiptId) {
|
||||
if ( busy.has(receiptId) ) {
|
||||
throw new Error('This receipt is still being processed.');
|
||||
}
|
||||
busy.add(receiptId);
|
||||
}
|
||||
|
||||
function putReview (next, review) {
|
||||
if ( next.expenses.some(expense => expense.receiptId === review.receiptId) ) {
|
||||
throw new Error('This receipt is already in the report.');
|
||||
}
|
||||
const index = next.reviews.findIndex(item => item.receiptId === review.receiptId);
|
||||
if ( index !== -1 ) {
|
||||
next.reviews[index] = copy(review);
|
||||
} else if ( next.reviews.length >= MAX_REVIEWS ) {
|
||||
throw new Error('There are too many open reviews.');
|
||||
} else {
|
||||
next.reviews.push(copy(review));
|
||||
}
|
||||
}
|
||||
|
||||
async function extractReceipt (image, reply) {
|
||||
checkImage(image);
|
||||
const receiptId = await receiptIdOf(image);
|
||||
requireOpen();
|
||||
|
||||
const saved = draft.reviews.find(review => review.receiptId === receiptId);
|
||||
if ( saved ) {
|
||||
return copy(saved);
|
||||
}
|
||||
if ( draft.expenses.some(expense => expense.receiptId === receiptId) ) {
|
||||
throw new Error('This receipt is already in the report.');
|
||||
}
|
||||
if ( draft.expenses.length >= MAX_EXPENSES ) {
|
||||
throw new Error(`A report can have at most ${ MAX_EXPENSES } expenses.`);
|
||||
}
|
||||
if ( draft.reviews.length + extracting >= MAX_REVIEWS ) {
|
||||
throw new Error(`Approve or discard some open reviews first. At most ${ MAX_REVIEWS } can be open, counting receipts being read.`);
|
||||
}
|
||||
|
||||
lock(receiptId);
|
||||
extracting += 1;
|
||||
try {
|
||||
let review;
|
||||
try {
|
||||
const { fields, problems } = parseExtraction(reply === undefined ? await requestExtraction(image) : reply);
|
||||
review = toReview(receiptId, fields, problems);
|
||||
} catch ( error ) {
|
||||
const reason = String(error?.message ?? error?.error?.message ?? 'the request failed').slice(0, 200);
|
||||
review = toReview(receiptId, emptyFields(), [`No AI suggestions (${ reason }). Fill in each field from the receipt.`]);
|
||||
}
|
||||
|
||||
try {
|
||||
await commit(next => putReview(next, review));
|
||||
} catch ( error ) {
|
||||
return { ...copy(review), saveError: String(error?.message ?? 'unknown error') };
|
||||
}
|
||||
return copy(review);
|
||||
} finally {
|
||||
extracting -= 1;
|
||||
busy.delete(receiptId);
|
||||
}
|
||||
}
|
||||
|
||||
async function approveExpense (receiptId, values) {
|
||||
requireOpen();
|
||||
if ( ! isReceiptId(receiptId) ) {
|
||||
throw new Error('Approve a receipt by the receiptId of its review.');
|
||||
}
|
||||
const fields = Object.fromEntries(FIELDS.map(name => [name, values?.[name]]));
|
||||
const invalid = FIELDS.filter(name => ! isValid(name, fields[name]));
|
||||
if ( invalid.length > 0 ) {
|
||||
throw new Error(invalid.map(name => `The ${ name } must be ${ RULES[name] }.`).join(' '));
|
||||
}
|
||||
|
||||
const expense = { receiptId, ...fields };
|
||||
lock(receiptId);
|
||||
try {
|
||||
await commit(next => {
|
||||
if ( next.expenses.some(item => item.receiptId === receiptId) ) {
|
||||
throw new Error('This receipt is already in the report.');
|
||||
}
|
||||
if ( next.expenses.length >= MAX_EXPENSES ) {
|
||||
throw new Error(`A report can have at most ${ MAX_EXPENSES } expenses.`);
|
||||
}
|
||||
next.expenses.push(expense);
|
||||
next.reviews = next.reviews.filter(review => review.receiptId !== receiptId);
|
||||
});
|
||||
} finally {
|
||||
busy.delete(receiptId);
|
||||
}
|
||||
return copy(expense);
|
||||
}
|
||||
|
||||
const FORMULA_LIKE = /^[\s\x00-\x1F\x7F\u200B-\u200D\u2060]*[=+\-@]|^[\x00-\x1F\x7F]/;
|
||||
const PLAIN_NUMBER = /^[+-]?(\d{1,3}(,\d{3})+|\d+)(\.\d+)?$/;
|
||||
const CSV_COLUMNS = ['date', 'merchant', 'currency', 'total'];
|
||||
|
||||
function csvField (value) {
|
||||
let text = String(value ?? '');
|
||||
if ( FORMULA_LIKE.test(text) && ! PLAIN_NUMBER.test(text) ) {
|
||||
text = `'${ text }`;
|
||||
}
|
||||
return `"${ text.replaceAll('"', '""') }"`;
|
||||
}
|
||||
|
||||
function toCsv (rows, columns) {
|
||||
const lines = [columns, ...rows.map(row => columns.map(column => row[column]))];
|
||||
return `${ lines.map(fields => fields.map(csvField).join(',')).join('\r\n') }\r\n`;
|
||||
}
|
||||
|
||||
function exportCsv () {
|
||||
requireOpen();
|
||||
return toCsv(draft.expenses, CSV_COLUMNS);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// -- Review UI --
|
||||
// AI suggestions only reach the page as input values and text.
|
||||
|
||||
const pickButton = document.getElementById('pick');
|
||||
const reopenButton = document.getElementById('reopen');
|
||||
const approveButton = document.getElementById('approve');
|
||||
const saveButton = document.getElementById('save');
|
||||
const status = document.getElementById('status');
|
||||
const preview = document.getElementById('preview');
|
||||
const problemList = document.getElementById('problems');
|
||||
const reportSummary = document.getElementById('report');
|
||||
const inputs = Object.fromEntries(FIELDS.map(name => [name, document.getElementById(name)]));
|
||||
|
||||
let review = null;
|
||||
let working = false;
|
||||
|
||||
const messageOf = error => String(error?.message ?? error?.error?.message ?? error ?? 'unknown error');
|
||||
|
||||
function update () {
|
||||
pickButton.disabled = working;
|
||||
reopenButton.disabled = working || draft !== null;
|
||||
approveButton.disabled = working || review === null;
|
||||
saveButton.disabled = working || draft === null || draft.expenses.length === 0;
|
||||
reportSummary.textContent = draft === null
|
||||
? 'The report is not open.'
|
||||
: `The report has ${ draft.expenses.length } approved expenses and ${ draft.reviews.length } open reviews.`;
|
||||
}
|
||||
|
||||
function showReview (next, image) {
|
||||
review = next;
|
||||
preview.hidden = next === null;
|
||||
if ( image !== undefined ) {
|
||||
preview.src = image;
|
||||
}
|
||||
for ( const name of FIELDS ) {
|
||||
inputs[name].value = next?.fields[name] ?? '';
|
||||
}
|
||||
const notes = next === null ? [] : [
|
||||
...next.problems,
|
||||
...(next.unresolved.length > 0 ? [`Fill in from the receipt: ${ next.unresolved.join(', ') }.`] : []),
|
||||
];
|
||||
problemList.replaceChildren(...notes.map(note => {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = note;
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
// Runs one button action at a time. The task starts right away, so a picker it opens still counts as a user action.
|
||||
async function run (task) {
|
||||
working = true;
|
||||
update();
|
||||
try {
|
||||
await task();
|
||||
} finally {
|
||||
working = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
pickButton.addEventListener('click', () => run(async () => {
|
||||
let image;
|
||||
try {
|
||||
image = await pickReceiptImage();
|
||||
} catch ( error ) {
|
||||
status.textContent = `The image was not used (${ messageOf(error) }). Nothing was sent.`;
|
||||
return;
|
||||
}
|
||||
if ( image === null ) {
|
||||
status.textContent = 'No image was picked, so nothing was sent.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if ( draft === null ) {
|
||||
await openDraft();
|
||||
}
|
||||
status.textContent = 'Reading the receipt...';
|
||||
const next = await extractReceipt(image);
|
||||
showReview(next, image);
|
||||
status.textContent = next.saveError === undefined
|
||||
? 'Check every field against the receipt, correct it, then approve.'
|
||||
: `The review may not be saved. ${ next.saveError } Use Open report, then approve.`;
|
||||
} catch ( error ) {
|
||||
status.textContent = messageOf(error);
|
||||
}
|
||||
}));
|
||||
|
||||
approveButton.addEventListener('click', () => run(async () => {
|
||||
if ( review === null ) {
|
||||
return;
|
||||
}
|
||||
if ( draft === null ) {
|
||||
status.textContent = 'Open the report again with Open report, then approve.';
|
||||
return;
|
||||
}
|
||||
const values = Object.fromEntries(FIELDS.map(name => [name, inputs[name].value]));
|
||||
try {
|
||||
const expense = await approveExpense(review.receiptId, values);
|
||||
showReview(null);
|
||||
status.textContent = `Approved the expense from ${ expense.merchant }. Save the report CSV to keep a copy in Puter.`;
|
||||
} catch ( error ) {
|
||||
status.textContent = draft === null
|
||||
? `${ messageOf(error) } Use Open report to load what was saved.`
|
||||
: messageOf(error);
|
||||
}
|
||||
}));
|
||||
|
||||
reopenButton.addEventListener('click', () => run(async () => {
|
||||
try {
|
||||
await openDraft();
|
||||
status.textContent = 'The report is open.';
|
||||
} catch ( error ) {
|
||||
status.textContent = `The report could not be opened (${ messageOf(error) }). Try again.`;
|
||||
}
|
||||
}));
|
||||
|
||||
saveButton.addEventListener('click', () => run(async () => {
|
||||
if ( draft === null ) {
|
||||
status.textContent = 'Open the report again with Open report, then save.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const saved = await saveCsvFile(exportCsv(), 'expense-report.csv');
|
||||
status.textContent = saved === null
|
||||
? 'Save cancelled. No CSV file was saved.'
|
||||
: `Saved the report as ${ saved.name }.`;
|
||||
} catch ( error ) {
|
||||
// A rejection can follow a completed write whose response was lost.
|
||||
status.textContent = `Save could not be confirmed (${ messageOf(error) }). Check the destination before retrying.`;
|
||||
}
|
||||
}));
|
||||
|
||||
update();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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).
|
||||
@@ -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 = '<img src=x onerror="alert(1)">';
|
||||
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('<svg onload="alert(1)"></svg>'), ''],
|
||||
['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 = '<img src=x onerror="globalThis.pwned = true">';
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user