From 34183968592663a12b1501becdb6af7120e2984f Mon Sep 17 00:00:00 2001
From: Hackall <36754621+hackall360@users.noreply.github.com>
Date: Sun, 14 Sep 2025 13:43:55 -0700
Subject: [PATCH] Improve polliLib docs, async image handling, and tests
---
.github/workflows/deploy-pages.yml | 30 ++++++-
docs/polliLib.md | 80 +++++++++++++++++++
js/chat/chat-storage.js | 33 +++++---
js/polliLib/polliLib-web.global.js | 34 +++++++-
js/polliLib/polliLib-web.global.js.bak | 34 +++++++-
js/polliLib/src/image.js | 26 +++++-
js/polliLib/src/models.js | 12 +++
js/polliLib/src/text.js | 3 +-
js/ui/simple.js | 33 +++++---
package.json | 2 +-
tests/fixtures/pending-image.json | 3 +
tests/pollilib-async-image.mjs | 29 +++++++
tests/pollilib-capabilities.mjs | 18 +++++
tests/pollilib-image-json.mjs | 17 ++++
tests/pollilib-referrer.mjs | 9 +++
tests/run-all.mjs | 52 ++++++++++++
.../{ai-response.mjs => site-ai-response.mjs} | 0
.../{json-repair.mjs => site-json-repair.mjs} | 0
tests/{json-tools.mjs => site-json-tools.mjs} | 0
...ion.mjs => site-markdown-sanitization.mjs} | 0
20 files changed, 379 insertions(+), 36 deletions(-)
create mode 100644 docs/polliLib.md
create mode 100644 js/polliLib/src/models.js
create mode 100644 tests/fixtures/pending-image.json
create mode 100644 tests/pollilib-async-image.mjs
create mode 100644 tests/pollilib-capabilities.mjs
create mode 100644 tests/pollilib-image-json.mjs
create mode 100644 tests/pollilib-referrer.mjs
create mode 100644 tests/run-all.mjs
rename tests/{ai-response.mjs => site-ai-response.mjs} (100%)
rename tests/{json-repair.mjs => site-json-repair.mjs} (100%)
rename tests/{json-tools.mjs => site-json-tools.mjs} (100%)
rename tests/{markdown-sanitization.mjs => site-markdown-sanitization.mjs} (100%)
diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index 0c2a707..bcdb572 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -20,8 +20,36 @@ concurrency:
cancel-in-progress: true
jobs:
+ tests:
+ name: Run Test Suites
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Run tests
+ run: |
+ npm test
+
+ - name: Capture test status
+ id: capture
+ run: |
+ cat tests/test-results.json
+ node -e "const fs=require('fs');const r=JSON.parse(fs.readFileSync('tests/test-results.json','utf8'));fs.appendFileSync(process.env.GITHUB_OUTPUT,`status=${r.status}\n`);"
+
+ - name: Report test summary
+ run: |
+ node -e "const fs=require('fs');const r=JSON.parse(fs.readFileSync('tests/test-results.json','utf8'));console.log('# Test Summary');console.log('PolliLib',r.groups.pollilib.passed,'/',r.groups.pollilib.total);console.log('Site',r.groups.site.passed,'/',r.groups.site.total);console.log('Overall',r.passed,'/',r.total,'->',r.status);" >> $GITHUB_STEP_SUMMARY
+
build:
name: Build and Upload Artifact
+ needs: tests
+ if: needs.tests.outputs.status != 'fail'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -49,7 +77,7 @@ jobs:
report-build-status:
name: Report Build Status
- needs: build
+ needs: [build, tests]
runs-on: ubuntu-latest
if: always()
steps:
diff --git a/docs/polliLib.md b/docs/polliLib.md
new file mode 100644
index 0000000..9f3570e
--- /dev/null
+++ b/docs/polliLib.md
@@ -0,0 +1,80 @@
+# PolliLib Usage Guide
+
+PolliLib provides a lightweight client for interacting with the Pollinations API from the browser or Node.js environments. This guide covers basic usage patterns for image, text and audio generation along with other helper utilities.
+
+## Installation
+
+PolliLib is bundled in this repository under `js/polliLib`. Include `polliLib-web.global.js` in the browser or import the individual modules from `js/polliLib/src` when using Node.js.
+
+```html
+
+
+```
+
+## Image Generation
+
+```javascript
+import { image } from './js/polliLib/src/image.js';
+
+const blob = await image('a tiny red square', {
+ width: 64,
+ height: 64,
+ json: false, // set to true to receive raw JSON metadata
+ retries: 5 // poll until the image is ready
+});
+```
+
+The call returns a `Blob` containing the generated PNG. Passing `json: true` forces Pollinations to return raw JSON when supported. When the service responds with a placeholder JSON payload, the function automatically polls until an actual image is available.
+
+## Text Generation
+
+```javascript
+import { text, chat } from './js/polliLib/src/text.js';
+
+const out = await text('Explain gravity in one sentence.', { model: 'openai' });
+
+const chatOut = await chat({
+ model: 'openai',
+ messages: [
+ { role: 'user', content: 'Say hello.' }
+ ],
+ json: true // request strict JSON formatting
+});
+```
+
+`text` returns a string (or an async iterator when `stream: true`). `chat` mirrors the OpenAI chat API and can also stream JSON objects when requested.
+
+## Audio Generation
+
+```javascript
+import { tts, stt } from './js/polliLib/src/audio.js';
+
+const speech = await tts('hello world', { voice: 'alloy' });
+const transcript = await stt({ data: myArrayBuffer, format: 'mp3' });
+```
+
+`tts` produces a spoken audio `Blob` using the `openai-audio` model. `stt` performs speech‑to‑text on a provided file or raw audio buffer.
+
+## Model Capabilities
+
+```javascript
+import { modelCapabilities } from './js/polliLib/src/models.js';
+
+const caps = await modelCapabilities();
+console.log(caps.image); // available image models
+console.log(caps.text); // available text models
+console.log(caps.audio); // audio voices (if available)
+```
+
+This helper combines information from the image and text model endpoints so applications can dynamically enable features based on available capabilities.
+
+## Other Utilities
+
+- **Feeds** – `imageFeed` and `textFeed` stream recent public generations.
+- **Tools & MCP** – helpers for creating tool calls and constructing MCP servers.
+- **Pipeline** – compose multi‑step workflows that mix text, image and audio steps.
+
+See the source files in `js/polliLib/src` for full details on each module.
+
diff --git a/js/chat/chat-storage.js b/js/chat/chat-storage.js
index 1229aa6..30c9d80 100644
--- a/js/chat/chat-storage.js
+++ b/js/chat/chat-storage.js
@@ -183,18 +183,27 @@ document.addEventListener("DOMContentLoaded", () => {
img.dataset.imageUrl = url;
img.dataset.imageId = imageId;
img.crossOrigin = "anonymous";
- img.onload = () => {
- loadingDiv.remove();
- img.style.display = "block";
- attachImageButtons(img, imageId);
- };
- img.onerror = () => {
- loadingDiv.innerHTML = "⚠️ Failed to load image";
- loadingDiv.style.display = "flex";
- loadingDiv.style.justifyContent = "center";
- loadingDiv.style.alignItems = "center";
- };
- imageContainer.appendChild(img);
+ let attempts = 0;
+ const maxAttempts = 5;
+ const tryReload = () => {
+ if (attempts++ >= maxAttempts) {
+ loadingDiv.innerHTML = "⚠️ Failed to load image";
+ loadingDiv.style.display = "flex";
+ loadingDiv.style.justifyContent = "center";
+ loadingDiv.style.alignItems = "center";
+ return;
+ }
+ setTimeout(() => {
+ img.src = url + (url.includes('?') ? '&' : '?') + 'retry=' + Date.now();
+ }, 1000 * attempts);
+ };
+ img.onload = () => {
+ loadingDiv.remove();
+ img.style.display = "block";
+ attachImageButtons(img, imageId);
+ };
+ img.onerror = tryReload;
+ imageContainer.appendChild(img);
const imgButtonContainer = document.createElement("div");
imgButtonContainer.className = "image-button-container";
imgButtonContainer.dataset.imageId = imageId;
diff --git a/js/polliLib/polliLib-web.global.js b/js/polliLib/polliLib-web.global.js
index 916e32e..45e4bf4 100644
--- a/js/polliLib/polliLib-web.global.js
+++ b/js/polliLib/polliLib-web.global.js
@@ -71,6 +71,7 @@
// --- helpers ---
const bool = v => (v == null ? undefined : (v ? 'true' : 'false'));
+ const sleep = ms => new Promise(res => setTimeout(res, ms));
function base64FromArrayBuffer(ab) {
const bytes = new Uint8Array(ab);
let binary = '';
@@ -83,7 +84,7 @@
}
// --- image.js ---
- async function image(prompt, { model, seed, width, height, image: imgUrl, nologo, private: priv, enhance, safe, referrer } = {}, client = getDefaultClient()) {
+ async function image(prompt, { model, seed, width, height, image: imgUrl, nologo, private: priv, enhance, safe, referrer, json, retries = 5, retryDelayMs = 1000 } = {}, client = getDefaultClient()) {
const url = `${client.imageBase}/prompt/${encodeURIComponent(prompt)}`;
const params = {};
if (model) params.model = model;
@@ -96,8 +97,24 @@
if (enhance != null) params.enhance = bool(enhance);
if (safe != null) params.safe = bool(safe);
if (referrer) params.referrer = referrer;
- const r = await client.get(url, { params });
+ if (json) params.json = 'true';
+ const headers = json ? { Accept: 'application/json' } : {};
+ const r = await client.get(url, { params, headers });
if (!r.ok) throw new Error(`image error ${r.status}`);
+ const ct = r.headers.get('content-type') ?? '';
+ if (ct.includes('application/json')) {
+ const data = await r.json();
+ if (json) return data;
+ if (data?.url) {
+ const ir = await fetch(data.url);
+ if (ir.ok) return await ir.blob();
+ }
+ if (retries > 0) {
+ await sleep(retryDelayMs);
+ return await image(prompt, { model, seed, width, height, image: imgUrl, nologo, private: priv, enhance, safe, referrer, json, retries: retries - 1, retryDelayMs }, client);
+ }
+ throw new Error('image pending');
+ }
return await r.blob();
}
async function imageModels(client = getDefaultClient()) {
@@ -131,7 +148,7 @@
return await r.text();
}
}
- async function chat({ model, messages, seed, temperature, top_p, presence_penalty, frequency_penalty, max_tokens, stream, private: priv, tools, tool_choice, referrer }, client = getDefaultClient()) {
+ async function chat({ model, messages, seed, temperature, top_p, presence_penalty, frequency_penalty, max_tokens, stream, private: priv, tools, tool_choice, referrer, json }, client = getDefaultClient()) {
const url = `${client.textBase}/openai`;
const body = { model, messages };
if (seed != null) body.seed = seed;
@@ -144,6 +161,7 @@
if (tools) body.tools = tools;
if (tool_choice) body.tool_choice = tool_choice;
if (referrer) body.referrer = referrer;
+ if (json) body.json = true;
if (stream) {
body.stream = true;
const r = await client.postJson(url, body, { headers: { 'Accept': 'text/event-stream' } });
@@ -266,6 +284,14 @@
async function listTextModels(client) { return await textModels(client); }
async function listAudioVoices(client) { const models = await textModels(client); return models?.['openai-audio']?.voices ?? []; }
+ async function modelCapabilities(client = getDefaultClient()) {
+ const [image, text] = await Promise.all([
+ imageModels(client).catch(() => ({})),
+ textModels(client).catch(() => ({})),
+ ]);
+ return { image, text, audio: text?.['openai-audio'] ?? {} };
+ }
+
// --- pipeline.js ---
class Context extends Map {}
class Pipeline { constructor() { this.steps = []; } step(s) { this.steps.push(s); return this; } async execute({ client, context = new Context() } = {}) { for (const s of this.steps) await s.run({ client, context }); return context; } }
@@ -282,7 +308,7 @@
const api = {
configure,
image, text, chat, search, tts, stt, vision,
- imageModels, textModels, imageFeed, textFeed,
+ imageModels, textModels, imageFeed, textFeed, modelCapabilities,
tools: { functionTool, ToolBox, chatWithTools },
mcp: { serverName, toolDefinitions, generateImageUrl, generateImageBase64, listImageModels, listTextModels, listAudioVoices },
pipeline: { Context, Pipeline, TextGetStep, ImageStep, TtsStep, VisionUrlStep },
diff --git a/js/polliLib/polliLib-web.global.js.bak b/js/polliLib/polliLib-web.global.js.bak
index 024cab2..b1b3e5b 100644
--- a/js/polliLib/polliLib-web.global.js.bak
+++ b/js/polliLib/polliLib-web.global.js.bak
@@ -71,6 +71,7 @@
// --- helpers ---
const bool = v => (v == null ? undefined : (v ? 'true' : 'false'));
+ const sleep = ms => new Promise(res => setTimeout(res, ms));
function base64FromArrayBuffer(ab) {
const bytes = new Uint8Array(ab);
let binary = '';
@@ -83,7 +84,7 @@
}
// --- image.js ---
- async function image(prompt, { model, seed, width, height, image: imgUrl, nologo, private: priv, enhance, safe, referrer } = {}, client = getDefaultClient()) {
+ async function image(prompt, { model, seed, width, height, image: imgUrl, nologo, private: priv, enhance, safe, referrer, json, retries = 5, retryDelayMs = 1000 } = {}, client = getDefaultClient()) {
const url = `${client.imageBase}/prompt/${encodeURIComponent(prompt)}`;
const params = {};
if (model) params.model = model;
@@ -96,8 +97,24 @@
if (enhance != null) params.enhance = bool(enhance);
if (safe != null) params.safe = bool(safe);
if (referrer) params.referrer = referrer;
- const r = await client.get(url, { params });
+ if (json) params.json = 'true';
+ const headers = json ? { Accept: 'application/json' } : {};
+ const r = await client.get(url, { params, headers });
if (!r.ok) throw new Error(`image error ${r.status}`);
+ const ct = r.headers.get('content-type') ?? '';
+ if (ct.includes('application/json')) {
+ const data = await r.json();
+ if (json) return data;
+ if (data?.url) {
+ const ir = await fetch(data.url);
+ if (ir.ok) return await ir.blob();
+ }
+ if (retries > 0) {
+ await sleep(retryDelayMs);
+ return await image(prompt, { model, seed, width, height, image: imgUrl, nologo, private: priv, enhance, safe, referrer, json, retries: retries - 1, retryDelayMs }, client);
+ }
+ throw new Error('image pending');
+ }
return await r.blob();
}
async function imageModels(client = getDefaultClient()) {
@@ -131,7 +148,7 @@
return await r.text();
}
}
- async function chat({ model, messages, seed, temperature, top_p, presence_penalty, frequency_penalty, max_tokens, stream, private: priv, tools, tool_choice, referrer }, client = getDefaultClient()) {
+ async function chat({ model, messages, seed, temperature, top_p, presence_penalty, frequency_penalty, max_tokens, stream, private: priv, tools, tool_choice, referrer, json }, client = getDefaultClient()) {
const url = `${client.textBase}/openai`;
const body = { model, messages };
if (seed != null) body.seed = seed;
@@ -144,6 +161,7 @@
if (tools) body.tools = tools;
if (tool_choice) body.tool_choice = tool_choice;
if (referrer) body.referrer = referrer;
+ if (json) body.json = true;
if (stream) {
body.stream = true;
const r = await client.postJson(url, body, { headers: { 'Accept': 'text/event-stream' } });
@@ -266,6 +284,14 @@
async function listTextModels(client) { return await textModels(client); }
async function listAudioVoices(client) { const models = await textModels(client); return models?.['openai-audio']?.voices ?? []; }
+ async function modelCapabilities(client = getDefaultClient()) {
+ const [image, text] = await Promise.all([
+ imageModels(client).catch(() => ({})),
+ textModels(client).catch(() => ({})),
+ ]);
+ return { image, text, audio: text?.['openai-audio'] ?? {} };
+ }
+
// --- pipeline.js ---
class Context extends Map {}
class Pipeline { constructor() { this.steps = []; } step(s) { this.steps.push(s); return this; } async execute({ client, context = new Context() } = {}) { for (const s of this.steps) await s.run({ client, context }); return context; } }
@@ -284,7 +310,7 @@
const api = {
configure,
image, text, chat, search, tts, stt, vision,
- imageModels, textModels, imageFeed, textFeed,
+ imageModels, textModels, imageFeed, textFeed, modelCapabilities,
tools: { functionTool, ToolBox, chatWithTools },
mcp: { serverName, toolDefinitions, generateImageUrl, generateImageBase64, listImageModels, listTextModels, listAudioVoices },
pipeline: { Context, Pipeline, TextGetStep, ImageStep, TtsStep, VisionUrlStep },
diff --git a/js/polliLib/src/image.js b/js/polliLib/src/image.js
index adae2e0..0491020 100644
--- a/js/polliLib/src/image.js
+++ b/js/polliLib/src/image.js
@@ -1,9 +1,11 @@
import { getDefaultClient } from './client.js';
const bool = v => (v == null ? undefined : (v ? 'true' : 'false'));
+const sleep = ms => new Promise(res => setTimeout(res, ms));
export async function image(prompt, {
model, seed, width, height, image, nologo, private: priv, enhance, safe, referrer,
+ json, retries = 5, retryDelayMs = 1000,
} = {}, client = getDefaultClient()) {
const url = `${client.imageBase}/prompt/${encodeURIComponent(prompt)}`;
const params = {};
@@ -17,9 +19,31 @@ export async function image(prompt, {
if (enhance != null) params.enhance = bool(enhance);
if (safe != null) params.safe = bool(safe);
if (referrer) params.referrer = referrer;
+ if (json) params.json = 'true';
- const r = await client.get(url, { params });
+ const headers = json ? { Accept: 'application/json' } : {};
+
+ const r = await client.get(url, { params, headers });
if (!r.ok) throw new Error(`image error ${r.status}`);
+
+ const ct = r.headers.get('content-type') ?? '';
+ if (ct.includes('application/json')) {
+ const data = await r.json();
+ if (json) return data;
+ if (data?.url) {
+ const ir = await fetch(data.url);
+ if (ir.ok) return await ir.blob();
+ }
+ if (retries > 0) {
+ await sleep(retryDelayMs);
+ return await image(prompt, {
+ model, seed, width, height, image, nologo, private: priv,
+ enhance, safe, referrer, json, retries: retries - 1, retryDelayMs,
+ }, client);
+ }
+ throw new Error('image pending');
+ }
+
return await r.blob();
}
diff --git a/js/polliLib/src/models.js b/js/polliLib/src/models.js
new file mode 100644
index 0000000..dfa6516
--- /dev/null
+++ b/js/polliLib/src/models.js
@@ -0,0 +1,12 @@
+import { getDefaultClient } from './client.js';
+import { imageModels } from './image.js';
+import { textModels } from './text.js';
+
+export async function modelCapabilities(client = getDefaultClient()) {
+ const [image, text] = await Promise.all([
+ imageModels(client).catch(() => ({})),
+ textModels(client).catch(() => ({})),
+ ]);
+ return { image, text, audio: text?.['openai-audio'] ?? {} };
+}
+
diff --git a/js/polliLib/src/text.js b/js/polliLib/src/text.js
index d2fa713..0bb31a5 100644
--- a/js/polliLib/src/text.js
+++ b/js/polliLib/src/text.js
@@ -34,7 +34,7 @@ export async function text(prompt, {
}
}
-export async function chat({ model, messages, seed, temperature, top_p, presence_penalty, frequency_penalty, max_tokens, stream, private: priv, tools, tool_choice, referrer }, client = getDefaultClient()) {
+export async function chat({ model, messages, seed, temperature, top_p, presence_penalty, frequency_penalty, max_tokens, stream, private: priv, tools, tool_choice, referrer, json }, client = getDefaultClient()) {
const url = `${client.textBase}/openai`;
const body = { model, messages };
if (seed != null) body.seed = seed;
@@ -47,6 +47,7 @@ export async function chat({ model, messages, seed, temperature, top_p, presence
if (tools) body.tools = tools;
if (tool_choice) body.tool_choice = tool_choice;
if (referrer) body.referrer = referrer;
+ if (json) body.json = true;
if (stream) {
body.stream = true;
diff --git a/js/ui/simple.js b/js/ui/simple.js
index 7449e67..e2928ed 100644
--- a/js/ui/simple.js
+++ b/js/ui/simple.js
@@ -420,18 +420,27 @@ document.addEventListener("DOMContentLoaded", () => {
img.dataset.imageId = imageId;
img.crossOrigin = "anonymous";
- img.onload = () => {
- loadingDiv.remove();
- img.style.display = "block";
- attachImageButtonListeners(img, imageId);
- };
- img.onerror = () => {
- loadingDiv.innerHTML = "⚠️ Failed to load image";
- loadingDiv.style.display = "flex";
- loadingDiv.style.justifyContent = "center";
- loadingDiv.style.alignItems = "center";
- };
- imageContainer.appendChild(img);
+ let attempts = 0;
+ const maxAttempts = 5;
+ const tryReload = () => {
+ if (attempts++ >= maxAttempts) {
+ loadingDiv.innerHTML = "⚠️ Failed to load image";
+ loadingDiv.style.display = "flex";
+ loadingDiv.style.justifyContent = "center";
+ loadingDiv.style.alignItems = "center";
+ return;
+ }
+ setTimeout(() => {
+ img.src = url + (url.includes('?') ? '&' : '?') + 'retry=' + Date.now();
+ }, 1000 * attempts);
+ };
+ img.onload = () => {
+ loadingDiv.remove();
+ img.style.display = "block";
+ attachImageButtonListeners(img, imageId);
+ };
+ img.onerror = tryReload;
+ imageContainer.appendChild(img);
const imgButtonContainer = document.createElement("div");
imgButtonContainer.className = "simple-image-button-container";
diff --git a/package.json b/package.json
index 349f012..0b0fdb4 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node tests/pollilib-smoke.mjs && node tests/markdown-sanitization.mjs && node tests/ai-response.mjs && node tests/json-tools.mjs && node tests/json-repair.mjs"
+ "test": "node tests/run-all.mjs"
},
"devDependencies": {
"marked": "^11.2.0"
diff --git a/tests/fixtures/pending-image.json b/tests/fixtures/pending-image.json
new file mode 100644
index 0000000..5586c1e
--- /dev/null
+++ b/tests/fixtures/pending-image.json
@@ -0,0 +1,3 @@
+{
+ "url": "https://example.com/final.png"
+}
diff --git a/tests/pollilib-async-image.mjs b/tests/pollilib-async-image.mjs
new file mode 100644
index 0000000..6d6767a
--- /dev/null
+++ b/tests/pollilib-async-image.mjs
@@ -0,0 +1,29 @@
+import assert from 'assert/strict';
+import fs from 'fs/promises';
+import { image } from '../js/polliLib/src/image.js';
+
+let calls = 0;
+const pending = JSON.parse(await fs.readFile(new URL('./fixtures/pending-image.json', import.meta.url), 'utf8'));
+const client = {
+ imageBase: 'https://example.com',
+ async get(url, opts = {}) {
+ calls++;
+ if (calls === 1) {
+ return {
+ ok: true,
+ headers: { get: () => 'application/json' },
+ async json() { return pending; },
+ };
+ }
+ return {
+ ok: true,
+ headers: { get: () => 'image/png' },
+ async blob() { return new Blob(['x'], { type: 'image/png' }); },
+ };
+ }
+};
+
+global.fetch = async () => ({ ok: true, blob: async () => new Blob(['x'], { type: 'image/png' }) });
+
+const blob = await image('test', {}, client);
+assert(blob instanceof Blob);
diff --git a/tests/pollilib-capabilities.mjs b/tests/pollilib-capabilities.mjs
new file mode 100644
index 0000000..d8c296d
--- /dev/null
+++ b/tests/pollilib-capabilities.mjs
@@ -0,0 +1,18 @@
+import assert from 'assert/strict';
+import { modelCapabilities } from '../js/polliLib/src/models.js';
+
+const client = {
+ imageBase: 'https://img.example',
+ textBase: 'https://txt.example',
+ async get(url) {
+ if (url.startsWith('https://img.example')) {
+ return { ok: true, async json() { return { foo: {} }; }, headers: { get: () => 'application/json' } };
+ }
+ return { ok: true, async json() { return { bar: {}, 'openai-audio': { voices: ['a'] } }; }, headers: { get: () => 'application/json' } };
+ }
+};
+
+const caps = await modelCapabilities(client);
+assert('foo' in caps.image);
+assert('bar' in caps.text);
+assert.deepEqual(caps.audio.voices, ['a']);
diff --git a/tests/pollilib-image-json.mjs b/tests/pollilib-image-json.mjs
new file mode 100644
index 0000000..6b748f2
--- /dev/null
+++ b/tests/pollilib-image-json.mjs
@@ -0,0 +1,17 @@
+import assert from 'assert/strict';
+import { image } from '../js/polliLib/src/image.js';
+
+const client = {
+ imageBase: 'https://example.com',
+ async get(url, { params, headers } = {}) {
+ assert.equal(headers.Accept, 'application/json');
+ return {
+ ok: true,
+ headers: { get: () => 'application/json' },
+ async json() { return { prompt: params.model }; },
+ };
+ }
+};
+
+const data = await image('test', { model: 'foo', json: true }, client);
+assert.equal(data.prompt, 'foo');
diff --git a/tests/pollilib-referrer.mjs b/tests/pollilib-referrer.mjs
new file mode 100644
index 0000000..1ea9fe0
--- /dev/null
+++ b/tests/pollilib-referrer.mjs
@@ -0,0 +1,9 @@
+import assert from 'assert/strict';
+import { PolliClientWeb } from '../js/polliLib/src/client.js';
+
+let lastUrl = '';
+global.fetch = async (url, opts) => { lastUrl = url.toString(); return { ok: true, blob: async () => new Blob() }; };
+
+const client = new PolliClientWeb({ referrer: 'test.com', imageBase: 'https://img', textBase: 'https://txt' });
+await client.get('https://img/prompt/cat');
+assert(lastUrl.includes('referrer=test.com'));
diff --git a/tests/run-all.mjs b/tests/run-all.mjs
new file mode 100644
index 0000000..c7aa99d
--- /dev/null
+++ b/tests/run-all.mjs
@@ -0,0 +1,52 @@
+import fs from 'fs/promises';
+import path from 'path';
+import { spawn } from 'child_process';
+
+const __dirname = path.dirname(new URL(import.meta.url).pathname);
+const files = (await fs.readdir(__dirname))
+ .filter(f => f.endsWith('.mjs') && f !== 'run-all.mjs');
+
+const groups = { pollilib: [], site: [] };
+for (const f of files) {
+ if (f.startsWith('pollilib-')) groups.pollilib.push(f);
+ else if (f.startsWith('site-')) groups.site.push(f);
+}
+
+async function run(file) {
+ return new Promise(resolve => {
+ const proc = spawn(process.argv[0], [path.join(__dirname, file)], { stdio: 'inherit' });
+ proc.on('close', code => resolve({ file, ok: code === 0 }));
+ });
+}
+
+async function runGroup(list) {
+ const out = [];
+ for (const f of list) out.push(await run(f));
+ return out;
+}
+
+const groupResults = {};
+for (const [name, list] of Object.entries(groups)) {
+ groupResults[name] = await runGroup(list);
+}
+const results = Object.values(groupResults).flat();
+const passed = results.filter(r => r.ok).length;
+const total = results.length;
+const ratio = total === 0 ? 1 : passed / total;
+let status = 'fail';
+if (ratio >= 0.8) status = 'pass';
+else if (ratio >= 0.5) status = 'partial';
+const summary = {
+ passed,
+ total,
+ ratio,
+ status,
+ groups: Object.fromEntries(
+ Object.entries(groupResults).map(([k, v]) => [k, {
+ passed: v.filter(r => r.ok).length,
+ total: v.length
+ }])
+ )
+};
+await fs.writeFile(path.join(__dirname, 'test-results.json'), JSON.stringify(summary, null, 2));
+if (status === 'fail') process.exit(1);
diff --git a/tests/ai-response.mjs b/tests/site-ai-response.mjs
similarity index 100%
rename from tests/ai-response.mjs
rename to tests/site-ai-response.mjs
diff --git a/tests/json-repair.mjs b/tests/site-json-repair.mjs
similarity index 100%
rename from tests/json-repair.mjs
rename to tests/site-json-repair.mjs
diff --git a/tests/json-tools.mjs b/tests/site-json-tools.mjs
similarity index 100%
rename from tests/json-tools.mjs
rename to tests/site-json-tools.mjs
diff --git a/tests/markdown-sanitization.mjs b/tests/site-markdown-sanitization.mjs
similarity index 100%
rename from tests/markdown-sanitization.mjs
rename to tests/site-markdown-sanitization.mjs