diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..89ebc32 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test diff --git a/.gitignore b/.gitignore index 7cfd02b..7a1f823 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store *.log node_modules/ +.omx/ diff --git a/README.md b/README.md index 0a46f87..e6ced89 100644 --- a/README.md +++ b/README.md @@ -44,19 +44,28 @@ Instead it: | Command | Result | |---|---| -| `/html-last` | Opens a render-mode chooser | +| `/html-last` | Opens quick local HTML without starting a Pi model turn | +| `/html-last choose` | Opens a render-mode chooser | | `/html-last local` | Forces quick local HTML | | `/html-last pi` | Forces designed HTML via the current Pi model | | `/html-last gemini` | Forces designed HTML via Gemini CLI | | `/html-last-version` | Shows the loaded extension version | -## Installation +## Quick start -### Oh My Pi / OMP +Native Pi npm install: -OMP auto-discovers native extensions from `~/.omp/agent/extensions`. +```bash +pi install npm:pi-html-long-answer-extension +``` + +Native Pi git install: + +```bash +pi install git:https://github.com/zakelfassi/pi-html-long-answer-extension.git +``` -Global install: +Oh My Pi / OMP global install: ```bash mkdir -p ~/.omp/agent/extensions @@ -64,80 +73,98 @@ git clone https://github.com/zakelfassi/pi-html-long-answer-extension.git \ ~/.omp/agent/extensions/html-long-answer ``` -One-off test without installing globally: +Then ask for a long answer and run: -```bash -omp -e /absolute/path/to/index.js +```text +/html-last-version +/html-last local ``` -### Legacy Pi +## Installation and compatibility matrix -Pi supports extension installation from git and also supports direct extension loading. +This package is published to npm for Pi package discovery and remains installable directly from git for pinned or source-reviewed installs. -Install from git: +| Harness / platform | Install or load path | Verify after install | Expected result | Status | +|---|---|---|---|---| +| Native Pi npm package | `pi install npm:pi-html-long-answer-extension` | Run `/html-last-version`, then `/html-last local` after a long answer | Version notification, then a browser-opened local HTML export | Supported package path; appears in npm-backed Pi package discovery | +| Native / legacy Pi git | `pi install git:https://github.com/zakelfassi/pi-html-long-answer-extension.git` | Run `/html-last-version`, then `/html-last local` after a long answer | Version notification, then a browser-opened local HTML export | Supported source install path; verify on your installed Pi version | +| Manual Pi extension root | Clone to `~/.pi/agent/extensions/html-long-answer` | Restart/load Pi, then run `/html-last-version` and `/html-last local` | Extension auto-loads from the global root | Supported path; verify on your installed Pi version | +| Oh My Pi / OMP | Clone to `~/.omp/agent/extensions/html-long-answer` | Restart/load OMP, then run `/html-last-version` and `/html-last local` | Extension auto-loads from the OMP global root | Supported path; verify on your installed OMP version | +| OMP one-off test | `omp -e /absolute/path/to/index.js` | Run `/html-last-version` | Version notification appears | Supported one-off smoke path | +| Pi-compatible derived harnesses | Use the Pi npm/git/manual instructions if the harness honors Pi `package.json.pi.extensions` or Pi extension roots | Run `/html-last-version` and `/html-last local` | Same command behavior as Pi | Harness-specific commands are unverified | +| LazyPi | No LazyPi-specific command is documented here | Use the Pi-compatible row only if your LazyPi setup exposes Pi-compatible extension loading | Do not assume a LazyPi-only install command | Exact LazyPi third-party extension flow unverified | +| Gemini CLI | Optional external renderer used by `/html-last gemini` | Run `/html-last gemini` after a long answer | Designed HTML export, or a clean fallback to local HTML if Gemini is unavailable/invalid | Optional | -```bash -pi install git:https://github.com/zakelfassi/pi-html-long-answer-extension.git -``` +## Verify after install -Or place it manually in the legacy global extension root: +| Command | Expected result | +|---|---| +| `/html-last-version` | Shows the loaded `html-long-answer` version | +| `/html-last` | Writes a local HTML artifact and opens it in the default browser without starting a Pi model turn | +| `/html-last choose` | Opens the render-mode chooser when UI selection is available | +| `/html-last local` | Writes a local HTML artifact and opens it in the default browser | +| `/html-last pi` | Queues a current-model designed HTML pass, then writes the result or falls back safely | +| `/html-last gemini` | Uses Gemini CLI when available; invalid/unsafe/non-HTML output falls back to local HTML | + +For reproducible installs, pin an npm version or a git ref/tag once you choose a release: ```bash -mkdir -p ~/.pi/agent/extensions -git clone https://github.com/zakelfassi/pi-html-long-answer-extension.git \ - ~/.pi/agent/extensions/html-long-answer +pi install npm:pi-html-long-answer-extension@0.2.0 +pi install git:https://github.com/zakelfassi/pi-html-long-answer-extension.git@v0.2.0 ``` -### Shipping support for both runtimes - -This repo includes both extension manifest keys in `package.json`: -- `omp.extensions` -- `pi.extensions` - -That keeps directory/package resolution compatible with both ecosystems. - ## Runtime behavior - Long answers are detected from message length / line / paragraph thresholds. - Long answers are captured into session state so `/html-last` can work on prior assistant replies. - Local and designed exports open automatically in the browser after the file is written. - Raw URLs such as `https://example.com` are linkified in local exports. -- Gemini rich renders are validated for actual HTML; invalid output falls back to the local renderer. +- Rich Pi/Gemini renders must be standalone HTML documents with inline CSS only. +- Rich HTML is validated before writing: scripts, event-handler attributes, `javascript:` URLs, external assets, external CSS URLs, unsafe tags, oversized output, and overly complex output are rejected or routed to fallback behavior. +- Invalid, unsafe, or non-HTML rich output falls back to the local renderer instead of writing a malformed nested document. ## Repo layout ```text html-long-answer/ +├── .github/ +│ └── workflows/ +│ └── ci.yml ├── assets/ │ ├── flow.svg │ ├── hero.svg │ └── render-modes.svg +├── test/ +│ └── extension.test.js ├── index.js ├── package.json +├── pnpm-lock.yaml ├── README.md └── .gitignore ``` ## Development notes -The extension currently lives and runs as a single-file runtime module (`index.js`) so it is easy to install directly into an extension root. +The extension runtime still lives in a single file (`index.js`) so it is easy to install directly into a Pi or OMP extension root. Tests and CI live outside the runtime path. + +Use PNPM: + +```bash +pnpm install +pnpm test +``` If you modify it, re-test these flows: - long answer -> lightweight notice only -- `/html-last` -> chooser appears +- `/html-last` -> local HTML writes and opens without starting a Pi model turn +- `/html-last choose` -> chooser appears - `/html-last local` -> HTML writes and opens -- `/html-last pi` -> second-pass render path queues/runs +- `/html-last pi` -> second-pass render path queues/runs and validates rich HTML - `/html-last gemini` -> Gemini render path succeeds or cleanly falls back - `/html-last-version` -> version shown in-session ## Trust and security -Extensions run with your user permissions. Only install from sources you trust. - -## Compatibility notes - -This repo is designed to ship to: -- Oh My Pi / OMP users through `~/.omp/agent/extensions` -- legacy Pi users through `pi install ...` or `~/.pi/agent/extensions` +Extensions run with your user permissions. Only install from sources you trust, review the source before installing, and pin a git ref or tag when you need reproducible behavior. -The install paths and dual manifest strategy are based on the upstream Pi and OMP extension-loading models. +Rich HTML generated by Pi or Gemini is treated as untrusted until it passes this extension's validation. The validator is intentionally conservative: if rich output includes active scripts, event handlers, external assets, or unsafe URLs, the extension falls back to local HTML rather than writing the rich document. diff --git a/index.js b/index.js index 01292d0..58a0043 100644 --- a/index.js +++ b/index.js @@ -4,11 +4,11 @@ const fs = require('fs/promises'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); -const { execFile } = require('child_process'); +const { execFile, spawn } = require('child_process'); const { promisify } = require('util'); const execFileAsync = promisify(execFile); -const EXPORT_ROOT = path.join(os.tmpdir(), 'pi-html-exports'); +const DEFAULT_EXPORT_ROOT = path.join(os.tmpdir(), 'pi-html-exports'); const PREF_ENTRY_TYPE = 'html-long-answer-pref'; const SOURCE_ENTRY_TYPE = 'html-long-answer-source'; const EXPORT_ENTRY_TYPE = 'html-long-answer-export'; @@ -17,6 +17,16 @@ const LONG_ANSWER_DEFAULTS = { minLines: 24, minParagraphs: 6, }; +const MAX_RICH_HTML_CHARS = 512 * 1024; +const MAX_RICH_HTML_TAGS = 2500; +const BLOCKED_RICH_TAGS = /<\s*\/?\s*(?:script|iframe|object|embed|link|base|form|input|button|textarea|select|option)\b/i; +const BLOCKED_META_REFRESH = /<\s*meta\b[^>]*http-equiv\s*=\s*(['"]?)refresh\1/i; +const EVENT_HANDLER_ATTR = /\s+on[a-z]+\s*=/i; +const JAVASCRIPT_URL_ATTR = /\s(?:href|src|xlink:href|action|formaction)\s*=\s*(['\"]?)\s*javascript:/i; +const EXTERNAL_ASSET_ATTR = /(?:\s(?:src|poster)\s*=\s*(['\"]?)\s*(?:https?:)?\/\/|\ssrcset\s*=\s*(['\"]?)[^'\">]*(?:https?:)?\/\/|<\s*(?:image|use|feimage)\b[^>]*\s(?:href|xlink:href)\s*=\s*(['\"]?)\s*(?:https?:)?\/\/)/i; +const EXTERNAL_CSS_URL = /(?:url\(\s*(['\"]?)\s*(?:https?:)?\/\/|@import\s+(?:url\(\s*)?(['\"]?)\s*(?:https?:)?\/\/)/i; +const OPEN_FAILURE_WINDOW_MS = 1000; + function sha(input) { return crypto.createHash('sha1').update(String(input || '')).digest('hex'); @@ -448,12 +458,17 @@ async function ensureDir(dir) { await fs.mkdir(dir, { recursive: true }); } +function getExportRoot() { + return process.env.PI_HTML_LONG_ANSWER_EXPORT_ROOT || DEFAULT_EXPORT_ROOT; +} + async function writeHtmlArtifact({ title, bodyHtml, sourceText, mode }) { - await ensureDir(EXPORT_ROOT); + const exportRoot = getExportRoot(); + await ensureDir(exportRoot); const now = new Date(); const iso = now.toISOString().replace(/[:.]/g, '-'); const fileName = `${iso}-${slugify(title)}-${mode}.html`; - const filePath = path.join(EXPORT_ROOT, fileName); + const filePath = path.join(exportRoot, fileName); const html = buildLocalHtmlDocument(title, bodyHtml, { exportedAt: now.toISOString(), words: wordCount(sourceText), @@ -466,6 +481,51 @@ async function writeHtmlArtifact({ title, bodyHtml, sourceText, mode }) { return filePath; } +async function writeRichHtmlArtifact({ title, htmlText }) { + const html = validateRichHtmlDocument(htmlText); + const exportRoot = getExportRoot(); + await ensureDir(exportRoot); + const now = new Date(); + const iso = now.toISOString().replace(/[:.]/g, '-'); + const fileName = `${iso}-${slugify(title)}-llm-enhanced.html`; + const filePath = path.join(exportRoot, fileName); + await fs.writeFile(filePath, html, 'utf8'); + return filePath; +} + +function validateRichHtmlDocument(htmlText) { + const html = String(htmlText || '').trim(); + if (!html) { + throw new Error('Rich HTML output was empty.'); + } + if (html.length > MAX_RICH_HTML_CHARS) { + throw new Error(`Rich HTML output exceeded ${MAX_RICH_HTML_CHARS} characters.`); + } + const tagCount = (html.match(/<\/?[a-z][^>]*>/gi) || []).length; + if (tagCount > MAX_RICH_HTML_TAGS) { + throw new Error(`Rich HTML output exceeded ${MAX_RICH_HTML_TAGS} HTML tags.`); + } + if (!/]/i.test(html) || !/]/i.test(html)) { + throw new Error('Rich HTML output must be a standalone document with and .'); + } + if (BLOCKED_RICH_TAGS.test(html)) { + throw new Error('Rich HTML output contained a blocked HTML tag.'); + } + if (BLOCKED_META_REFRESH.test(html)) { + throw new Error('Rich HTML output contained a meta refresh.'); + } + if (EVENT_HANDLER_ATTR.test(html)) { + throw new Error('Rich HTML output contained an event-handler attribute.'); + } + if (JAVASCRIPT_URL_ATTR.test(html)) { + throw new Error('Rich HTML output contained a javascript: URL.'); + } + if (EXTERNAL_ASSET_ATTR.test(html) || EXTERNAL_CSS_URL.test(html)) { + throw new Error('Rich HTML output referenced an external asset.'); + } + return /^\n${html}`; +} + function isLongAnswer(text, config) { const source = String(text || '').trim(); if (!source) return false; @@ -485,6 +545,55 @@ function parseArgs(rawArgs) { return []; } +function parseHtmlLastInput(text) { + const source = typeof text === 'string' ? text.trim() : ''; + if (/^\/html-last-version\s*$/i.test(source)) { + return { command: 'version', args: '' }; + } + + const match = /^\/html-last(?:\s+([\s\S]*))?$/i.exec(source); + if (!match) return null; + return { command: 'export', args: match[1] || '' }; +} + +async function resolveOpenCommand(command) { + if (!command) return null; + if (path.isAbsolute(command)) { + try { + await fs.access(command, fs.constants.X_OK); + return command; + } catch (_) { + return null; + } + } + + const searchPath = String(process.env.PATH || '').split(path.delimiter).filter(Boolean); + for (const directory of searchPath) { + const candidate = path.join(directory, command); + try { + await fs.access(candidate, fs.constants.X_OK); + return candidate; + } catch (_) { + // Keep searching PATH. + } + } + return null; +} + +function resolveForcedExportMode(rawArgs) { + const parsedArgs = parseArgs(rawArgs); + if (parsedArgs.some((arg) => /^(choose|chooser|menu)$/i.test(arg))) return 'choose'; + if (parsedArgs.some((arg) => /^(gemini)$/i.test(arg))) return 'rich-gemini'; + if (parsedArgs.some((arg) => /^(pi|claude|current)$/i.test(arg))) return 'rich-pi'; + if (parsedArgs.some((arg) => /^(local|quick)$/i.test(arg))) return 'local'; + if (parsedArgs.some((arg) => /^(rich|enhanced|designed)$/i.test(arg))) return 'rich-pi'; + return null; +} + +function hasSelectableUi(ctx) { + return Boolean(ctx && ctx.ui && typeof ctx.ui.select === 'function'); +} + function extractHtmlDocument(text) { const source = String(text || '').trim(); if (!source) return null; @@ -604,6 +713,10 @@ module.exports = function htmlLongAnswerExtension(pi) { } } + function notifyCommandError(ctx, error) { + notify(ctx, `Long Answer HTML command error: ${error && error.message ? error.message : String(error)} [html-long-answer ${EXTENSION_VERSION}]`, 'error'); + } + async function isGeminiCliAvailable() { if (typeof state.geminiAvailable === 'boolean') return state.geminiAvailable; try { @@ -616,19 +729,41 @@ module.exports = function htmlLongAnswerExtension(pi) { } async function openArtifact(filePath) { - try { - if (process.platform === 'darwin') { - await execFileAsync('/usr/bin/open', [filePath], { timeout: 3000 }); - return true; - } - if (process.platform === 'linux') { - await execFileAsync('xdg-open', [filePath], { timeout: 3000 }); - return true; + const command = process.platform === 'darwin' + ? '/usr/bin/open' + : process.platform === 'linux' + ? 'xdg-open' + : null; + const executable = await resolveOpenCommand(command); + if (!executable) return false; + + return new Promise((resolve) => { + let child; + let settled = false; + let timer; + + const settle = (opened) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(opened); + }; + + try { + child = spawn(executable, [filePath], { + detached: true, + stdio: 'ignore', + }); + } catch (_) { + settle(false); + return; } - } catch (_) { - // Best effort only. - } - return false; + + child.once('error', () => settle(false)); + child.once('exit', (code) => settle(code === 0)); + child.unref(); + timer = setTimeout(() => settle(true), OPEN_FAILURE_WINDOW_MS); + }); } async function maybeOpenArtifact(ctx, filePath, mode) { @@ -676,11 +811,9 @@ module.exports = function htmlLongAnswerExtension(pi) { } async function exportRichHtmlResult(ctx, source, htmlText) { - const filePath = await writeHtmlArtifact({ + const filePath = await writeRichHtmlArtifact({ title: source.title, - bodyHtml: htmlText, - sourceText: source.text, - mode: 'llm-enhanced', + htmlText, }); const meta = { path: filePath, @@ -695,9 +828,10 @@ module.exports = function htmlLongAnswerExtension(pi) { return meta; } - function normalizeChoice(result) { + function normalizeChoice(result, options) { if (typeof result === 'string') return result; if (typeof result === 'number') { + if (Array.isArray(options) && options[result]) return options[result].value; return ['local', 'rich', 'inline', 'never'][result] || null; } if (result && typeof result === 'object') { @@ -722,7 +856,7 @@ module.exports = function htmlLongAnswerExtension(pi) { const prompt = `Long answer detected — ${summary}`; try { const result = await ui.select(prompt, options); - return normalizeChoice(result) || null; + return normalizeChoice(result, options) || null; } catch (_) { return null; } @@ -812,14 +946,14 @@ module.exports = function htmlLongAnswerExtension(pi) { try { const result = await ctx.ui.select('Choose HTML render mode', options); - return normalizeChoice(result) || options[0].value; + return normalizeChoice(result, options) || options[0].value; } catch (_) { return geminiAvailable ? 'rich-gemini' : 'rich-pi'; } } function notifyLongAnswerAvailable(ctx, source) { - notify(ctx, `Long answer captured for HTML export (${source.stats.words} words). Run /html-last for choices, /html-last gemini for Gemini, or /html-last pi for the current Pi model. [html-long-answer ${EXTENSION_VERSION}]`, 'info'); + notify(ctx, `Long answer captured for HTML export (${source.stats.words} words). Run /html-last for quick local HTML, /html-last choose for choices, /html-last gemini for Gemini, or /html-last pi for the current Pi model. [html-long-answer ${EXTENSION_VERSION}]`, 'info'); } async function handleChoice(choice, ctx, source) { @@ -853,7 +987,12 @@ module.exports = function htmlLongAnswerExtension(pi) { const htmlDocument = extractHtmlDocument(info.text); if (htmlDocument) { - await exportRichHtmlResult(ctx, state.pendingRichExport.source, htmlDocument); + try { + await exportRichHtmlResult(ctx, state.pendingRichExport.source, htmlDocument); + } catch (error) { + await notify(ctx, `Richer HTML pass was unsafe or invalid: ${error && error.message ? error.message : String(error)}. Wrote a fallback HTML export instead. [html-long-answer ${EXTENSION_VERSION}]`, 'warning'); + await exportLocalHtml(ctx, state.pendingRichExport.source, 'llm-enhanced-fallback'); + } } else { await exportLocalHtml(ctx, { ...state.pendingRichExport.source, @@ -901,23 +1040,10 @@ module.exports = function htmlLongAnswerExtension(pi) { return; } - const parsedArgs = parseArgs(args); - const wantsGemini = parsedArgs.some((arg) => /^(gemini)$/i.test(arg)); - const wantsPi = parsedArgs.some((arg) => /^(pi|claude|current)$/i.test(arg)); - const wantsLocal = parsedArgs.some((arg) => /^(local|quick)$/i.test(arg)); - const wantsRich = parsedArgs.some((arg) => /^(rich|enhanced|designed)$/i.test(arg)); - - let mode = 'local'; - if (wantsGemini) { - mode = 'rich-gemini'; - } else if (wantsPi) { - mode = 'rich-pi'; - } else if (wantsLocal) { - mode = 'local'; - } else if (wantsRich) { - mode = 'rich-pi'; - } else if (ctx && ctx.hasUI) { - mode = await chooseCommandExportMode(ctx); + const forcedMode = resolveForcedExportMode(args); + let mode = forcedMode || 'local'; + if (mode === 'choose') { + mode = hasSelectableUi(ctx) ? await chooseCommandExportMode(ctx) : 'local'; } if (mode === 'rich-gemini') { @@ -933,7 +1059,11 @@ module.exports = function htmlLongAnswerExtension(pi) { } if (typeof pi.setLabel === 'function') { - pi.setLabel(`Long Answer HTML ${EXTENSION_VERSION}`); + try { + pi.setLabel(`Long Answer HTML ${EXTENSION_VERSION}`); + } catch (_) { + // Some hosts reject action methods during extension loading. + } } const restoreHandler = async (_event, ctx) => { @@ -944,6 +1074,22 @@ module.exports = function htmlLongAnswerExtension(pi) { pi.on('session_start', restoreHandler); pi.on('session_branch', restoreHandler); pi.on('session_tree', restoreHandler); + pi.on('input', async (event, ctx) => { + const parsedInput = parseHtmlLastInput(event && event.text); + if (!parsedInput) return undefined; + + try { + if (parsedInput.command === 'version') { + notify(ctx, `html-long-answer ${EXTENSION_VERSION}`, 'info'); + } else { + await exportLatestFromCommand(parsedInput.args, ctx); + } + } catch (error) { + notifyCommandError(ctx, error); + } + + return { handled: true, action: 'handled' }; + }); pi.on('message_end', async (event, ctx) => { try { await handleAssistantMessage(event, ctx); @@ -955,10 +1101,10 @@ module.exports = function htmlLongAnswerExtension(pi) { if (typeof pi.registerCommand === 'function') { pi.registerCommand('html-last', { - description: 'Export the latest eligible assistant answer as HTML. Use `gemini`, `pi`, or `local` to force a render path.', + description: 'Export the latest eligible assistant answer as HTML. Use `choose`, `gemini`, `pi`, or `local` to force a render path.', handler: (args, ctx) => { void exportLatestFromCommand(args, ctx).catch((error) => { - notify(ctx, `Long Answer HTML command error: ${error && error.message ? error.message : String(error)} [html-long-answer ${EXTENSION_VERSION}]`, 'error'); + notifyCommandError(ctx, error); }); }, }); @@ -971,3 +1117,20 @@ module.exports = function htmlLongAnswerExtension(pi) { }); } }; + +module.exports._internals = { + buildLocalHtmlDocument, + buildRichHtmlPrompt, + extractHtmlDocument, + formatInline, + getExportRoot, + parseArgs, + parseHtmlLastInput, + resolveOpenCommand, + hasSelectableUi, + renderMarkdownish, + resolveForcedExportMode, + validateRichHtmlDocument, + writeHtmlArtifact, + writeRichHtmlArtifact, +}; diff --git a/package.json b/package.json index 9bdfda8..ca9e440 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,26 @@ { "name": "pi-html-long-answer-extension", - "version": "0.1.0", - "private": true, + "version": "0.2.0", + "private": false, "description": "Long-answer HTML export extension for Oh My Pi and Pi.", "type": "commonjs", "main": "index.js", + "scripts": { + "test": "node --test test/*.test.js" + }, + "files": [ + "index.js", + "README.md", + "assets/" + ], "repository": { "type": "git", "url": "https://github.com/zakelfassi/pi-html-long-answer-extension.git" }, + "homepage": "https://github.com/zakelfassi/pi-html-long-answer-extension#readme", + "bugs": { + "url": "https://github.com/zakelfassi/pi-html-long-answer-extension/issues" + }, "keywords": [ "oh-my-pi", "omp", @@ -16,7 +28,11 @@ "extension", "html", "export", - "gemini" + "gemini", + "long-answer", + "html-export", + "pi-extension", + "pi-package" ], "engines": { "node": ">=20" @@ -30,5 +46,6 @@ "extensions": [ "./index.js" ] - } + }, + "packageManager": "pnpm@8.15.0+sha512.ea45517d5285d123eac02c3793505fa1fd6da90a2fc60d1e8d9e0c1e9292886ecfaff513f062b9d1cc8021bb8615033b1ac5bea3b2ee3fc165a6d7034bbe6b03" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..2b9f188 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false diff --git a/test/extension.test.js b/test/extension.test.js new file mode 100644 index 0000000..f6fb81b --- /dev/null +++ b/test/extension.test.js @@ -0,0 +1,225 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const repoRoot = path.resolve(__dirname, '..'); +const packageJson = require('../package.json'); +const extension = require('../index.js'); +const internals = extension._internals; + +function richDocument(body, head = '') { + return ` + +${head} +${body} +`; +} + +async function withTempExportRoot(fn) { + const previous = process.env.PI_HTML_LONG_ANSWER_EXPORT_ROOT; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'html-long-answer-test-')); + process.env.PI_HTML_LONG_ANSWER_EXPORT_ROOT = tempDir; + try { + return await fn(tempDir); + } finally { + if (previous === undefined) { + delete process.env.PI_HTML_LONG_ANSWER_EXPORT_ROOT; + } else { + process.env.PI_HTML_LONG_ANSWER_EXPORT_ROOT = previous; + } + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +test('package metadata preserves npm Pi and OMP entry contracts', () => { + assert.equal(packageJson.private, false); + assert.equal(packageJson.type, 'commonjs'); + assert.equal(packageJson.main, 'index.js'); + assert.equal(packageJson.engines.node, '>=20'); + assert.equal(packageJson.pi.extensions[0], './index.js'); + assert.equal(packageJson.omp.extensions[0], './index.js'); + assert.ok(packageJson.keywords.includes('pi-package')); + assert.ok(packageJson.keywords.includes('pi-extension')); + assert.deepEqual(packageJson.files, ['index.js', 'README.md', 'assets/']); + assert.ok(packageJson.scripts.test.includes('node --test')); + + for (const entry of [packageJson.main, packageJson.pi.extensions[0], packageJson.omp.extensions[0]]) { + const resolved = path.resolve(repoRoot, entry); + assert.equal(resolved, path.join(repoRoot, 'index.js')); + } +}); + +test('manifest entries load the same extension factory shape', () => { + const byMain = require(path.join(repoRoot, packageJson.main)); + const byPi = require(path.resolve(repoRoot, packageJson.pi.extensions[0])); + const byOmp = require(path.resolve(repoRoot, packageJson.omp.extensions[0])); + + assert.equal(typeof byMain, 'function'); + assert.equal(byMain, byPi); + assert.equal(byMain, byOmp); + assert.equal(typeof byMain._internals.validateRichHtmlDocument, 'function'); +}); + +test('extension registers commands/events and handles long assistant messages', async () => { + assert.doesNotThrow(() => extension({})); + assert.doesNotThrow(() => extension({ setLabel: () => { throw new Error('not initialized'); } })); + + const labels = []; + const events = new Map(); + const commands = new Map(); + const entries = []; + const notifications = []; + extension({ + setLabel: (label) => labels.push(label), + on: (eventName, handler) => events.set(eventName, handler), + registerCommand: (name, definition) => commands.set(name, definition), + appendEntry: async (type, data) => entries.push({ type, data }), + }); + + assert.match(labels[0], /^Long Answer HTML /); + assert.equal(typeof events.get('session_start'), 'function'); + assert.equal(typeof events.get('session_branch'), 'function'); + assert.equal(typeof events.get('session_tree'), 'function'); + assert.equal(typeof events.get('message_end'), 'function'); + assert.equal(typeof events.get('input'), 'function'); + assert.equal(typeof commands.get('html-last').handler, 'function'); + assert.equal(typeof commands.get('html-last-version').handler, 'function'); + + const inputResult = await events.get('input')({ text: '/html-last' }, { + ui: { notify: (message) => notifications.push(message) }, + }); + assert.deepEqual(inputResult, { handled: true, action: 'handled' }); + + const commandResult = commands.get('html-last').handler('', { + ui: { notify: (message) => notifications.push(message) }, + }); + assert.equal(commandResult, undefined); + await Promise.resolve(); + assert.equal(notifications.some((message) => message.includes('No eligible assistant answer')), true); + + const longText = `# Captured Answer\n\n${'This answer is long enough to trigger capture. '.repeat(80)}`; + await events.get('message_end')({ message: { role: 'assistant', text: longText } }, { + hasUI: true, + ui: { notify: (message) => notifications.push(message) }, + }); + + assert.equal(entries.some((entry) => entry.type === 'html-long-answer-source'), true); + assert.equal(notifications.some((message) => message.includes('Long answer captured for HTML export')), true); +}); + +test('local export preserves shell and representative markdown-ish rendering', async () => { + await withTempExportRoot(async () => { + const sourceText = [ + '# Local Export Title', + '', + 'Paragraph with https://example.com and `inlineCode`.', + '', + '- first item', + '- second item', + '', + '| Name | Value |', + '|---|---|', + '| alpha | beta |', + ].join('\n'); + const bodyHtml = internals.renderMarkdownish(sourceText); + const filePath = await internals.writeHtmlArtifact({ + title: 'Local Export Title', + bodyHtml, + sourceText, + mode: 'local', + }); + const html = await fs.readFile(filePath, 'utf8'); + + assert.match(html, /
Pi HTML export<\/div>/); + assert.match(html, /Mode<\/strong>
local/); + assert.match(html, /

Local Export Title<\/h2>/); + assert.match(html, /href="https:\/\/example\.com"/); + assert.match(html, /inlineCode<\/code>/); + assert.match(html, /
  • first item<\/li>
  • second item<\/li><\/ul>/); + assert.match(html, //); + assert.doesNotMatch(html, /'), + richDocument('
    bad
    '), + richDocument('bad'), + richDocument('bad'), + richDocument('
    bad
    '), + richDocument('bad'), + richDocument(''), + richDocument(''), + richDocument('
    bad
    '), + richDocument('

    refresh

    ', ''), + 'missing html wrapper', + richDocument('x'.repeat(513 * 1024)), + ]; + + for (const html of invalidCases) { + assert.throws(() => internals.validateRichHtmlDocument(html), /Rich HTML output|blocked|event-handler|javascript|external|meta refresh|exceeded|standalone/); + } +}); + +test('open command resolution refuses missing launchers', async () => { + await withTempExportRoot(async (tempDir) => { + const previousPath = process.env.PATH; + const launcher = path.join(tempDir, 'fake-open'); + await fs.writeFile(launcher, '#!/bin/sh\nexit 0\n', 'utf8'); + await fs.chmod(launcher, 0o755); + process.env.PATH = tempDir; + + try { + assert.equal(await internals.resolveOpenCommand('missing-open'), null); + assert.equal(await internals.resolveOpenCommand('fake-open'), launcher); + assert.equal(await internals.resolveOpenCommand(launcher), launcher); + assert.equal(await internals.resolveOpenCommand(path.join(tempDir, 'not-executable')), null); + } finally { + if (previousPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = previousPath; + } + } + }); +}); + +test('rich extraction and command mode parsing are deterministic', () => { + const fenced = 'prefix\n```html\n

    ok

    \n```\nsuffix'; + assert.equal(internals.extractHtmlDocument(fenced), '

    ok

    '); + assert.equal(internals.extractHtmlDocument('plain text'), null); + + assert.deepEqual(internals.parseArgs({ args: ['gemini'] }), ['gemini']); + assert.equal(internals.resolveForcedExportMode('gemini'), 'rich-gemini'); + assert.equal(internals.resolveForcedExportMode(['pi']), 'rich-pi'); + assert.equal(internals.resolveForcedExportMode({ args: ['quick'] }), 'local'); + assert.equal(internals.resolveForcedExportMode('choose'), 'choose'); + assert.equal(internals.hasSelectableUi({ ui: { select: () => 'local' } }), true); + assert.equal(internals.hasSelectableUi({ hasUI: true, ui: {} }), false); + assert.deepEqual(internals.parseHtmlLastInput('/html-last quick'), { command: 'export', args: 'quick' }); + assert.deepEqual(internals.parseHtmlLastInput(' /html-last-version '), { command: 'version', args: '' }); + assert.equal(internals.parseHtmlLastInput('/html-lastly'), null); + assert.equal(internals.resolveForcedExportMode('designed'), 'rich-pi'); + assert.equal(internals.resolveForcedExportMode(''), null); +});