Files
builder/scripts/test-view-image.mjs
Nariman Jelveh 70b08abb8f add image, video and audio generation
Port GenerateImage, GenerateVideo and GenerateSpeech from ../ai, with
their chat cards (js/media.js): pending card with a timer, image grid for
several versions, full-screen image viewer, Download / Open, and replay
of past generations when a chat is reopened.

- generated files live in a per-chat .media/<chatId> folder beside the
  project, so version snapshots and restores never touch them; deleting
  a chat removes it, duplicating a chat copies it
- save_to_project puts a file into the site (counts as a project change);
  copy and ViewImage can read from the chat's media folder
- system prompt: custom imagery for sites (max 4 images per turn), video
  only on explicit request, speech only (no music or sound effects)
- Plausible events: Image/Video/Audio Generated and ... Generation Failed,
  one per request, with the request's options and a failure reason;
  prompts and file names are never sent
- tests: generated-media (incl. analytics) plus ai's card, lightbox and
  read-URL tests; path-scoping and load-media-urls updated
2026-10-19 20:47:27 -07:00

131 lines
6.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
// ---- Regression guard: ViewImage never puts a block the API rejects into the
// history ---------------------------------------------------------------------
// A tool result is persisted in the chat and re-sent with every later request,
// so a block the API refuses doesn't fail one request — it fails every request
// after it, for good. ViewImage used to let three such blocks through:
// * an SVG's entire source as text (an exported illustration is megabytes:
// "prompt is too long" from then on), uncapped unlike ReadTextFile;
// * an image labelled by its EXTENSION, not its bytes — a WebP or JPEG saved
// under a .png name is common for images from the web, and the API rejects
// a media_type that doesn't match the data;
// * bytes that aren't an image the API takes (AVIF/HEIC/BMP behind a .png
// name, a corrupt file), passed through untouched.
// It also kept images up to 4MB raw, which is 5.33MB base64 — over the 5MB limit.
//
// Functional: runs the real tool against a stubbed filesystem/browser.
let failures = 0;
function check(name, cond, detail) {
if (cond) console.log('ok - ' + name);
else { console.error('FAIL - ' + name + (detail ? '\n ' + detail : '')); failures++; }
}
const src = fs.readFileSync(new URL('../src/tools/fs/view_image.js', import.meta.url), 'utf8');
const readSrc = fs.readFileSync(new URL('../src/tools/fs/read.js', import.meta.url), 'utf8');
const win = { tools: [], assertPathInProject: (p) => p, assertPathInProjectOrMedia: (p) => p };
const puterProxy = { fs: { read: (...a) => globalThis.puter.fs.read(...a) } };
new Function('window', 'puter', readSrc)(win, puterProxy);
new Function('window', 'puter', src)(win, puterProxy);
const I = win.__viewImageInternals;
const tool = win.tools.find((t) => t.function.name === 'ViewImage');
check('tool registered with the internals exposed', !!tool && typeof I.sniffImageType === 'function');
// --- Content sniffing --------------------------------------------------------
const PNG = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
const JPEG = [0xFF, 0xD8, 0xFF, 0xE0];
const GIF = [...Buffer.from('GIF89a')];
const WEBP = [...Buffer.from('RIFF'), 1, 2, 3, 4, ...Buffer.from('WEBPVP8 ')];
const AVIF = [0, 0, 0, 0x1C, ...Buffer.from('ftypavif')];
check('sniffs PNG', I.sniffImageType(Uint8Array.from(PNG)) === 'image/png');
check('sniffs JPEG', I.sniffImageType(Uint8Array.from(JPEG)) === 'image/jpeg');
check('sniffs GIF', I.sniffImageType(Uint8Array.from(GIF)) === 'image/gif');
check('sniffs WebP', I.sniffImageType(Uint8Array.from(WEBP)) === 'image/webp');
check('a RIFF that is not WebP (e.g. WAV) is not an image', I.sniffImageType(Uint8Array.from([...Buffer.from('RIFF'), 0, 0, 0, 0, ...Buffer.from('WAVE')])) === null);
check('AVIF is not one of the API\'s formats', I.sniffImageType(Uint8Array.from(AVIF)) === null);
check('empty input is not an image', I.sniffImageType(new Uint8Array(0)) === null);
check('raw byte cap leaves the base64 payload under the 5MB limit', I.VISION_MAX_BYTES * 4 / 3 < 5 * 1024 * 1024);
// --- The tool, end to end ------------------------------------------------------
globalThis.FileReader = class {
readAsDataURL(blob) {
blob.arrayBuffer().then((buf) => {
this.result = 'data:x;base64,' + Buffer.from(buf).toString('base64');
this.onload();
});
}
};
// Decoding is stubbed per scenario: `decodes` false = the browser can't read it.
function stubBrowser({ decodes = true, width = 800, height = 600 } = {}) {
globalThis.createImageBitmap = async () => {
if (!decodes) throw new Error('decode failed');
return { width, height, close() {} };
};
globalThis.document = {
createElement: () => ({
width: 0, height: 0,
getContext: () => ({ drawImage() {} }),
toBlob(cb, type) { cb(new Blob([Uint8Array.from(type === 'image/png' ? PNG : JPEG)], { type })); },
}),
};
}
async function run(name, bytes, opts) {
stubBrowser(opts);
const blob = new Blob([bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes)]);
globalThis.puter = { fs: { read: async () => blob } };
try { return { ok: true, res: await tool.exec({ path: '/u/AppData/app/c1/assets/' + name }, { appDir: '/u/AppData/app/c1' }) }; }
catch (e) { return { ok: false, err: e }; }
}
const mediaOf = (r) => r.ok && r.res.__contentBlocks[1] && r.res.__contentBlocks[1].source.media_type;
{
const r = await run('photo.png', [...PNG, 1, 2, 3]);
check('a real PNG is shown as image/png', mediaOf(r) === 'image/png', JSON.stringify(r.err?.message));
}
{
const r = await run('photo.png', [...WEBP, 1, 2, 3]);
check('WebP bytes behind a .png name are labelled image/webp, not by the extension', mediaOf(r) === 'image/webp', JSON.stringify(r.err?.message || mediaOf(r)));
}
{
const r = await run('hero.jpg', [...PNG, 9, 9]);
check('PNG bytes behind a .jpg name are labelled image/png', mediaOf(r) === 'image/png');
}
{
const r = await run('pic.png', [...AVIF, 7, 7], { decodes: true });
check('decodable bytes in a format the API won\'t take are re-encoded to PNG/JPEG',
r.ok && ['image/png', 'image/jpeg'].includes(mediaOf(r)), JSON.stringify(r.err?.message));
check('… and the caption doesn\'t claim a resize that didn\'t happen', r.ok && !/downscaled/.test(r.res.__contentBlocks[0].text));
}
{
const r = await run('pic.png', [...AVIF, 7, 7], { decodes: false });
check('undecodable non-image bytes are refused before they reach the history', !r.ok && /can't be viewed/.test(r.err.message));
}
{
const r = await run('broken.png', [...PNG, 0, 0], { decodes: false });
check('a corrupt image (valid header, won\'t decode) is refused too', !r.ok && /can't be viewed/.test(r.err.message));
}
{
const r = await run('big.png', [...PNG, 1], { width: 4000, height: 3000 });
check('an oversized image is still downscaled, with a caption saying so',
r.ok && /downscaled from 4000×3000/.test(r.res.__contentBlocks[0].text));
}
// --- SVG source is capped like ReadTextFile -----------------------------------
{
const huge = '<svg xmlns="http://www.w3.org/2000/svg">' + '<path d="M0 0L1 1"/>'.repeat(200000) + '</svg>';
const r = await run('art.svg', new TextEncoder().encode(huge));
const text = r.ok ? r.res.__contentBlocks[0].text : '';
check('a multi-megabyte SVG is truncated to the ReadTextFile cap',
r.ok && text.length < win.READ_TEXT_FILE_MAX_CHARS + 1000 && /truncated: this SVG is/.test(text),
`length ${text.length}`);
}
{
const small = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="4"/></svg>';
const r = await run('icon.svg', new TextEncoder().encode(small));
check('a normal SVG is returned whole', r.ok && r.res.__contentBlocks[0].text.endsWith(small));
}
if (failures) { console.error(`\n${failures} check(s) FAILED`); process.exit(1); }
console.log('\nAll view-image checks passed.');