Files
builder/scripts/test-read-url-cache.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

118 lines
6.2 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';
import { makeDom } from './lib/mini-dom.mjs';
// ---- Regression guard: reopening a chat reuses its media read URLs -----------
// Every puter.fs.getReadURL is a stat plus a new 24-hour access token, and
// nothing reused them: each open of a chat minted one per generated file again
// (20 images: 20, 40, 60 tokens after three opens) — which is what runs into
// Puter's rate limit on that call. A read URL for a file in the chat's media
// folder (unique names, never overwritten) is now reused for the session while
// its token is fresh; attachments, which can be overwritten, still get a new
// one each time. A reused URL that fails to load is dropped and replaced.
//
// Runs the real loadChat prefetch (sliced out of app.js) and the real
// displayURL / fillCard (src/js/media.js, on a small DOM stand-in).
let failures = 0;
function check(name, cond, detail) {
if (cond) console.log('ok - ' + name);
else { console.error('FAIL - ' + name + (detail ? '\n ' + detail : '')); failures++; }
}
const read = (rel) => fs.readFileSync(new URL(rel, import.meta.url), 'utf8');
const app = read('../src/js/app.js');
const mediaSrc = read('../src/js/media.js');
const load = app.slice(app.indexOf('async function loadChat('), app.indexOf('async function deleteChat('));
const a = load.indexOf('const mediaPaths = new Set();');
const b = load.indexOf('// Rebuild the chat display');
const m0 = app.indexOf('async function mapWithConcurrency(');
const mapSrc = app.slice(m0, app.indexOf('\n}\n', m0) + 2);
const h0 = app.indexOf('function mediaDisplayURL(');
if (a < 0 || b < 0 || h0 < 0 || h0 > m0) throw new Error('could not extract the media prefetch from loadChat');
const helperSrc = app.slice(h0, m0);
let now = Date.parse('2026-10-19T12:00:00Z');
const realNow = Date.now;
Date.now = () => now;
let tokens = 0;
let failNext = 0;
const puter = { fs: {
getReadURL: async (p) => {
if (failNext > 0) { failNext--; throw Object.assign(new Error('Too many requests.'), { code: 'too_many_requests' }); }
tokens++;
return `https://api.example/token-read?file=${encodeURIComponent(p)}&token=${tokens}`;
},
read: async () => new Blob([new Uint8Array([1, 2, 3])]),
} };
const { document, body, $ } = makeDom();
const win = { shouldAutoScroll: false };
new Function('window', '$', 'document', 'puter', 'localStorage', 'setInterval', 'clearInterval', 'requestAnimationFrame', mediaSrc)(
win, $, document, puter, { getItem: () => null }, () => 1, () => {}, (fn) => fn(),
);
const GM = win.GeneratedMedia;
const box = document.createElement('div');
box.setAttribute('class', 'chat-box');
body.appendChild(box);
const MEDIA = '/u/AppData/app/.media/chat1';
const history = [
{ role: 'system', content: 's' },
{ role: 'user', content: [{ type: 'text', text: 'logos' },
{ type: 'image-ref', path: '/u/AppData/app/chat1/assets/logo.svg' },
{ type: 'image-ref', path: '/u/AppData/app/chat1/assets/icon.svg' }] },
...Array.from({ length: 20 }, (_, i) => ({ role: 'user', content: { type: 'tool_result', tool_use_id: 't' + i,
content: JSON.stringify({ success: true, path: `${MEDIA}/image_${i}.png`, filename: `image_${i}.png` }) } })),
];
const openChat = new Function('window', 'puter', 'history', 'superseded', 'chat',
helperSrc + mapSrc + '\nreturn (async () => {\n' + load.slice(a, b) + '\nreturn { readURLFor, revoke: revokeChatMediaBlobURLs };\n})();');
const counts = [];
let last = null;
for (let i = 0; i < 3; i++) {
last = await openChat(win, puter, history, () => false, {});
counts.push(tokens);
last.revoke(); // the next open clears the chat box
}
console.log(` access tokens minted after 1, 2, 3 opens: ${counts.join(', ')} (was 22, 44, 66)`);
check('the first open mints one token per file, as before', counts[0] === 22, String(counts[0]));
check('reopening reuses the generated files’ URLs (only the 2 attachments get new ones)', counts[1] === 24 && counts[2] === 26, counts.join());
const u0 = await last.readURLFor(`${MEDIA}/image_0.png`);
check('…and hands back the same URL', /token=\d+$/.test(u0) && Number(u0.split('token=')[1]) <= 22, u0);
const att = await last.readURLFor('/u/AppData/app/chat1/assets/logo.svg');
check('an attachment (can be overwritten) is never reused', Number(att.split('token=')[1]) > 24, att);
// Tokens last 24 hours: after 23 a new one is minted.
now += 23 * 60 * 60 * 1000 + 1000;
const before = tokens;
const fresh = await GM.displayURL(`${MEDIA}/image_0.png`);
check('a URL older than 23 hours is replaced', tokens === before + 1 && fresh !== u0);
check('…and the new one is reused after that', (await GM.displayURL(`${MEDIA}/image_0.png`)) === fresh && tokens === before + 1);
// A failure is never cached: the blob fallback is released with the chat.
failNext = 1;
const fallback = await GM.displayURL(`${MEDIA}/new.png`);
check('a rate-limited request falls back to a blob: URL', /^blob:/.test(fallback));
const retried = await GM.displayURL(`${MEDIA}/new.png`);
check('…which is not reused: the next open asks for a real URL again', /^https:/.test(retried), retried);
// A reused URL that no longer loads (token revoked) is dropped and replaced.
const stale = await GM.displayURL(`${MEDIA}/image_5.png`);
const $card = GM.renderSaved({ success: true, kind: 'image', prompt: 'a fox', path: `${MEDIA}/image_5.png`, filename: 'image_5.png' }, stale);
const img = $card.find('img.generated-media-el')[0];
img.fire('error');
await new Promise((r) => setTimeout(r, 0));
const now5 = img.getAttribute('src');
check('a cached URL that fails to load is replaced with a fresh one', now5 !== stale && /^https:/.test(now5), now5);
check('…for the link, Open and Download too', $card.find('.generated-media-open').attr('href') === now5
&& $card.find('.generated-media-link').attr('href') === now5 && $card.find('.generated-media-download').attr('data-url') === now5);
check('…and the stale URL is no longer handed out', (await GM.displayURL(`${MEDIA}/image_5.png`)) === now5);
const t = tokens;
img.fire('error');
await new Promise((r) => setTimeout(r, 0));
check('only once per card (no retry loop on a file that is really gone)', tokens === t);
Date.now = realNow;
if (failures) { console.error(`\n${failures} check(s) FAILED`); process.exit(1); }
console.log('\nAll read-URL cache checks passed.');