diff --git a/README.md b/README.md index d92ead5..888e755 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Anatomy Gallery](plugins/zch0227/anatomy-gallery/) | A gallery for interactive isometric figures made with the anatomy skill: hang them in rooms you organize, view light and dark editions full screen, keep every version, and have an Agent create or edit figures from the gallery with a per-run process log. The anatomy skill is bundled. Requires MiniMax Code 3.1.2 or later | [zch0227](https://github.com/zch0227) | | [仓库透视 / Repo X-Ray](plugins/yujiachen-y/repo-xray/) | Understand a codebase from its Git history: a code map you can drill into, hotspots and bug magnets, high-risk files, owners and knowledge loss, commit rhythm and firefighting, and the tech stack. Works with GitHub, GitLab, and local repositories; analysis runs on your machine | [yujiachen-y](https://github.com/yujiachen-y) | | [Disk Insight](plugins/zch0227/disk-insight/) | See where disk space went on macOS and Windows with a sunburst space map, find junk files sorted into three safety tiers, large and duplicate files, and how much code repositories and Git worktrees take; ask an Agent about any item, and permanently delete what you select after a confirmation that lists every item. Requires MiniMax Code 3.1.2 or later | [zch0227](https://github.com/zch0227) | +| [Regex Lab](plugins/lym308758/regex-lab/) | A live regular-expression workbench: test patterns against sample text, inspect every match with capture groups and named groups, and preview replacements — computed entirely in the Mini App, so nothing leaves your machine. | [lym308758](https://github.com/lym308758) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index ceea4de..bb730a6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -75,6 +75,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [Anatomy 画廊](plugins/zch0227/anatomy-gallery/README.zh-CN.md) | 集中展示 anatomy 技能做出的交互式等距图解:按自建展区挂墙,全屏查看明暗两版,保留全部历史版本;在画廊里让 Agent 新建或修改作品,每次运行都有过程记录。自带 anatomy 技能。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) | | [仓库透视](plugins/yujiachen-y/repo-xray/README.zh-CN.md) | 从 Git 历史看懂一个代码仓库:可逐层下钻的代码地图、热点与 Bug 磁铁、高风险文件、负责人与知识流失、提交节奏与救火记录、技术栈;支持 GitHub、GitLab 和本机仓库,全部在本机分析 | [yujiachen-y](https://github.com/yujiachen-y) | | [磁盘透视](plugins/zch0227/disk-insight/README.zh-CN.md) | 查看 macOS 和 Windows 的磁盘空间去向:旭日图空间地图,按三档风险分类的垃圾文件,大文件和重复文件,代码仓库和 Git worktree 的占用;任何条目都可以问 Agent,选中的条目在确认框里逐一列出,确认后直接永久删除。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) | +| [Regex Lab](plugins/lym308758/regex-lab/) | 实时正则工作台:用样例文本测试正则,逐条查看匹配与捕获组 / 命名组,并预览替换结果;计算全部在 Mini App 内完成,数据不出本机 | [lym308758](https://github.com/lym308758) |
预览:Token 用量看板 diff --git a/plugins/lym308758/regex-lab/.minimax-plugin/plugin.json b/plugins/lym308758/regex-lab/.minimax-plugin/plugin.json new file mode 100644 index 0000000..ecda167 --- /dev/null +++ b/plugins/lym308758/regex-lab/.minimax-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "name": "regex-lab", + "displayName": "Regex Lab · 正则实验室", + "version": "1.0.0", + "description": "A live regular-expression workbench inside MiniMax Code: test patterns against sample text, inspect every match with capture groups and named groups, and preview replacements. Runs entirely in the Mini App — no data leaves your machine.", + "author": "lym308758", + "icon": "icon.png", + "category": "Code", + "exampleQueries": [ + "打开 Regex Lab 正则测试器", + "Open Regex Lab" + ], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/lym308758/regex-lab/LICENSE b/plugins/lym308758/regex-lab/LICENSE new file mode 100644 index 0000000..883444e --- /dev/null +++ b/plugins/lym308758/regex-lab/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 lym308758 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/lym308758/regex-lab/README.md b/plugins/lym308758/regex-lab/README.md new file mode 100644 index 0000000..e45969f --- /dev/null +++ b/plugins/lym308758/regex-lab/README.md @@ -0,0 +1,63 @@ +# Regex Lab (`regex-lab`) + +A live regular-expression workbench that runs inside MiniMax Code. Type a pattern and some +sample text, watch every match appear with its capture groups and named groups, and preview a +replacement — all computed by the Mini App's own Node backend. Nothing is sent anywhere; the text +never leaves the app. + +## What it does + +- **Live matching** against the sample text, with match position (`index`–`end`), the matched + substring, numbered capture groups, and named capture groups. +- **Replacement preview** using the standard `$1`, `$`, `$$`, `$&`, `` $` ``, `$'` tokens. +- **Flag toggles** for `g` / `i` / `m` / `s` / `u` / `y`, with a plain-text flag box if you prefer + to type them. +- **Invalid-pattern feedback** — a bad regex shows the engine's error instead of failing silently. + +The computation lives in `miniapp/node/regex-engine.mjs` (pure functions, no DOM), shared by the +Node HTTP API and the unit tests, so the logic you see is the logic that runs. + +## Install + +Copy this directory, including the hidden `.minimax-plugin/`, into the MiniMax Code plugins +directory as `regex-lab/` (`~/.minimax/plugins/regex-lab/` by default). Restart MiniMax Code and +ask the Agent to "Open Regex Lab" (or "打开 Regex Lab 正则测试器"). + +## Tested environment + +- Validated locally with Node.js 22: `node scripts/validate.mjs plugins/lym308758/regex-lab` reports + no errors, and `node --test plugins/lym308758/regex-lab/tests/` passes (engine + HTTP handler). +- **Not verified in the MiniMax Code host UI**: this environment has no MiniMax Code desktop client, + so the page was not opened inside the side panel and the in-app rendering/click-through was not + exercised end to end. The Node backend (page serving + `/api/match` + `/api/replace`) is covered + by `tests/server.test.mjs` against a real `node:http` server, and the regex engine by + `tests/regex-engine.test.mjs`. + +## Data & access + +- **Files:** reads only its own `miniapp/client/index.html` to serve the page. Writes nothing. +- **Network:** none beyond the Host loopback that serves the page and the JSON API to the page. +- **Processes:** none spawned. +- **Agent turns:** none; the Mini App declares no `agentAccess`. +- **State:** none stored. Each evaluation runs on the text you paste into the page. + +## Files + +```text +.minimax-plugin/plugin.json Plugin manifest +package.json Mini App declaration +miniapp/miniapp.json Payload roots, Node entry, page route +miniapp/client/index.html The page served at /dashboard +miniapp/node/server.mjs Node entry: start(context) -> { dispose } and the JSON API +miniapp/node/regex-engine.mjs Pure regex functions (match / replace), shared with the tests +icon.png Plugin icon +tests/regex-engine.test.mjs Engine unit tests +tests/server.test.mjs HTTP handler + start() lifecycle tests +README.md This file +README.zh-CN.md Chinese README +LICENSE MIT +``` + +## License + +[MIT](./LICENSE) diff --git a/plugins/lym308758/regex-lab/README.zh-CN.md b/plugins/lym308758/regex-lab/README.zh-CN.md new file mode 100644 index 0000000..470fa0b --- /dev/null +++ b/plugins/lym308758/regex-lab/README.zh-CN.md @@ -0,0 +1,40 @@ +# Regex Lab · 正则实验室(`regex-lab`) + +运行在 MiniMax Code 内部的实时正则工作台。输入正则和样例文本,逐条查看匹配结果及其捕获组、 +命名组,并预览替换效果——全部由 Mini App 自带的 Node 后端完成,文本不会发往任何外部地址。 + +## 功能 + +- **实时匹配**:显示每处匹配的位置(`index`–`end`)、匹配内容、编号捕获组与命名捕获组。 +- **替换预览**:支持标准 `$1`、`$`、`$$`、`$&`、`` $` ``、`$'` 等替换符。 +- **修饰符开关**:`g` / `i` / `m` / `s` / `u` / `y`,也可在文本框直接输入。 +- **非法正则提示**:正则无效时显示引擎报错,而不是静默失败。 + +计算逻辑位于 `miniapp/node/regex-engine.mjs`(纯函数、无 DOM),同时被 Node 接口与单元测试复用, +所见即所跑。 + +## 安装 + +将本目录(含隐藏目录 `.minimax-plugin/`)整体复制到 MiniMax Code 的插件目录,命名为 +`regex-lab/`(`~/.minimax/plugins/regex-lab/`)。重启 MiniMax Code,请 Agent 打开 +「Regex Lab 正则测试器」。 + +## 验证环境 + +- 本机使用 Node.js 22 校验通过:`node scripts/validate.mjs plugins/lym308758/regex-lab` 无错误, + `node --test plugins/lym308758/regex-lab/tests/` 全部通过(引擎 + HTTP 接口)。 +- **未在 MiniMax Code 宿主界面内验证**:当前环境没有 MiniMax Code 桌面端,因此未把页面真正在侧边栏 + 中打开,也未做端到端的界面点击验证。Node 后端(页面托管 + `/api/match` + `/api/replace`)由 + `tests/server.test.mjs` 在真实 `node:http` 服务上覆盖,正则引擎由 `tests/regex-engine.test.mjs` 覆盖。 + +## 数据访问 + +- **文件**:仅读取自身的 `miniapp/client/index.html` 以托管页面,不写入任何文件。 +- **网络**:除宿主回环(页面与 JSON 接口)外无任何外部网络访问。 +- **进程**:不创建子进程。 +- **Agent 回合**:无;本 Mini App 未声明 `agentAccess`。 +- **状态**:不存储任何状态,每次计算仅针对你粘贴的文本。 + +## 许可证 + +[MIT](./LICENSE) diff --git a/plugins/lym308758/regex-lab/icon.png b/plugins/lym308758/regex-lab/icon.png new file mode 100644 index 0000000..3730415 Binary files /dev/null and b/plugins/lym308758/regex-lab/icon.png differ diff --git a/plugins/lym308758/regex-lab/miniapp/client/index.html b/plugins/lym308758/regex-lab/miniapp/client/index.html new file mode 100644 index 0000000..eb127e6 --- /dev/null +++ b/plugins/lym308758/regex-lab/miniapp/client/index.html @@ -0,0 +1,257 @@ + + + + + + Regex Lab · 正则实验室 + + + +
+
+

Regex Lab · 正则实验室

+

实时测试正则表达式:逐条查看匹配、捕获组与命名组,并预览替换结果。计算全部在 Mini App 的 Node 后端完成,数据不出本机。

+
+ +
+ +
+ / + + / + +
+
+ + + + + + +
+
+ +
+ + +
+
+ +
+

Matches

+

输入有效的正则后会在这里显示匹配结果。

+
+ +
+ + +

Replaced result

+

+      
+
+ + + + diff --git a/plugins/lym308758/regex-lab/miniapp/miniapp.json b/plugins/lym308758/regex-lab/miniapp/miniapp.json new file mode 100644 index 0000000..d898d74 --- /dev/null +++ b/plugins/lym308758/regex-lab/miniapp/miniapp.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": ["./miniapp/client"], + "node": ["./miniapp/node"] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/dashboard" + }, + "mcpEndpoints": [] +} diff --git a/plugins/lym308758/regex-lab/miniapp/node/regex-engine.mjs b/plugins/lym308758/regex-lab/miniapp/node/regex-engine.mjs new file mode 100644 index 0000000..bb26299 --- /dev/null +++ b/plugins/lym308758/regex-lab/miniapp/node/regex-engine.mjs @@ -0,0 +1,94 @@ +// @ts-check + +/** + * @typedef {Object} MatchInfo + * @property {number} index Index of the match in the source text. + * @property {number} end Index just after the match. + * @property {string} value The matched substring. + * @property {Array<{ value: string | null }>} groups Capture groups, 1-based. + * @property {Record | null} namedGroups Named capture groups. + */ + +const MAX_MATCHES = 100000; + +/** + * Compile a pattern + flags, returning either a RegExp or the error message. + * @param {string} pattern + * @param {string} flags + * @returns {{ regex: RegExp | null, error: string | null }} + */ +export function compile(pattern, flags) { + try { + return { regex: new RegExp(pattern, flags), error: null }; + } catch (error) { + return { regex: null, error: error instanceof Error ? error.message : String(error) }; + } +} + +/** + * Run a regular expression against text and return every match. + * @param {string} text + * @param {string} pattern + * @param {string} [flags] + * @returns {{ ok: boolean, error?: string, global?: boolean, count?: number, matches?: MatchInfo[] }} + */ +export function runMatch(text, pattern, flags = '') { + const { regex, error } = compile(pattern, flags); + if (error) return { ok: false, error }; + const matches = []; + if (regex.global) { + let execResult; + let guard = 0; + while ((execResult = regex.exec(text)) !== null) { + matches.push(describeMatch(execResult)); + // A zero-width match leaves lastIndex unchanged and would loop forever; + // nudge it forward so the next search starts after the current position. + if (execResult.index === regex.lastIndex) regex.lastIndex += 1; + if (++guard > MAX_MATCHES) break; + } + } else { + const execResult = regex.exec(text); + if (execResult) matches.push(describeMatch(execResult)); + } + return { ok: true, global: regex.global, count: matches.length, matches }; +} + +/** + * Replace matches in text using a replacement string ($1, $, $$ …). + * @param {string} text + * @param {string} pattern + * @param {string} flags + * @param {string} replacement + * @returns {{ ok: boolean, error?: string, result?: string }} + */ +export function runReplace(text, pattern, flags, replacement) { + const { regex, error } = compile(pattern, flags); + if (error) return { ok: false, error }; + try { + return { ok: true, result: text.replace(regex, replacement) }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * @param {RegExpExecArray} m + * @returns {MatchInfo} + */ +function describeMatch(m) { + const groups = []; + for (let i = 1; i < m.length; i += 1) { + groups.push({ value: m[i] === undefined ? null : m[i] }); + } + const namedGroups = + m.groups != null + ? Object.fromEntries(Object.entries(m.groups).map(([key, value]) => [key, value ?? ''])) + : null; + return { + index: m.index, + end: m.index + m[0].length, + value: m[0], + groups, + namedGroups, + }; +} diff --git a/plugins/lym308758/regex-lab/miniapp/node/server.mjs b/plugins/lym308758/regex-lab/miniapp/node/server.mjs new file mode 100644 index 0000000..ae9865b --- /dev/null +++ b/plugins/lym308758/regex-lab/miniapp/node/server.mjs @@ -0,0 +1,117 @@ +// @ts-check + +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; + +import { runMatch, runReplace } from './regex-engine.mjs'; + +/** + * @typedef {import('./regex-engine.mjs').MatchInfo} MatchInfo + * @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext + * @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle + */ + +/** + * Build the HTTP request handler. Kept separate from start() so tests can + * exercise the full request path without a Host-provided context. + * @param {{ html: Buffer | string }} params + */ +export function createHandler({ html }) { + return async (request, response) => { + const url = new URL(request.url ?? '/', 'http://miniapp.local'); + const route = url.pathname; + try { + if (request.method === 'GET' && route === '/dashboard') { + sendHtml(response, 200, html); + return; + } + if (request.method === 'POST' && (route === '/api/match' || route === '/api/replace')) { + const body = await readJsonBody(request); + const text = String(body?.text ?? ''); + const pattern = String(body?.pattern ?? ''); + const flags = String(body?.flags ?? ''); + const payload = + route === '/api/match' + ? runMatch(text, pattern, flags) + : runReplace(text, pattern, flags, String(body?.replacement ?? '')); + sendJson(response, 200, payload); + return; + } + sendJson(response, 404, { error: 'not_found' }); + } catch (error) { + sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) }); + } + }; +} + +/** + * Node entry point: serve the static page and the JSON API. + * @param {MiniAppContext} context + * @returns {Promise} + */ +export async function start(context) { + const html = await readFile(join(context.pluginRoot, 'miniapp/client/index.html')); + const server = createServer(createHandler({ html })); + + await listen(server, context.listen.host, context.listen.port); + context.logger.info('miniapp.runtime.listening'); + + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + context.signal.removeEventListener('abort', onAbort); + await close(server); + }; + const onAbort = () => { + void dispose(); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} + +function readJsonBody(request) { + return new Promise((resolve, reject) => { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => { + if (chunks.length === 0) return resolve({}); + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch (error) { + reject(error); + } + }); + request.on('error', reject); + }); +} + +function sendHtml(response, status, body) { + response.writeHead(status, { 'content-type': 'text/html; charset=utf-8' }); + response.end(body); +} + +function sendJson(response, status, payload) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify(payload)); +} + +function listen(server, host, port) { + return new Promise((resolve, reject) => { + const onError = (error) => reject(error); + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + resolve(); + }); + }); +} + +function close(server) { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} diff --git a/plugins/lym308758/regex-lab/package.json b/plugins/lym308758/regex-lab/package.json new file mode 100644 index 0000000..2b6c04b --- /dev/null +++ b/plugins/lym308758/regex-lab/package.json @@ -0,0 +1,10 @@ +{ + "name": "regex-lab", + "version": "1.0.0", + "private": true, + "type": "module", + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} diff --git a/plugins/lym308758/regex-lab/tests/regex-engine.test.mjs b/plugins/lym308758/regex-lab/tests/regex-engine.test.mjs new file mode 100644 index 0000000..c01c780 --- /dev/null +++ b/plugins/lym308758/regex-lab/tests/regex-engine.test.mjs @@ -0,0 +1,75 @@ +// @ts-check +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { compile, runMatch, runReplace } from '../miniapp/node/regex-engine.mjs'; + +test('compile rejects an invalid pattern with an error string', () => { + const { regex, error } = compile('(', ''); + assert.equal(regex, null); + assert.ok(typeof error === 'string' && error.length > 0); +}); + +test('compile accepts a valid pattern', () => { + const { regex, error } = compile('\\d+', 'g'); + assert.ok(regex instanceof RegExp); + assert.equal(error, null); +}); + +test('runMatch finds one match without the global flag', () => { + const result = runMatch('abc 123 def', '\\d+', ''); + assert.equal(result.ok, true); + assert.equal(result.global, false); + assert.equal(result.count, 1); + assert.equal(result.matches[0].value, '123'); + assert.equal(result.matches[0].index, 4); + assert.equal(result.matches[0].end, 7); +}); + +test('runMatch finds every match with the global flag', () => { + const result = runMatch('a1 b2 c3', '\\d', 'g'); + assert.equal(result.count, 3); + assert.deepEqual( + result.matches.map((m) => m.value), + ['1', '2', '3'], + ); +}); + +test('runMatch reports numbered and named capture groups', () => { + const result = runMatch('2024-01', '(?\\d{4})-(\\d{2})', ''); + assert.equal(result.count, 1); + const m = result.matches[0]; + assert.equal(m.groups[0].value, '2024'); + assert.equal(m.groups[1].value, '01'); + assert.deepEqual(m.namedGroups, { year: '2024' }); +}); + +test('runMatch handles a zero-width match without looping forever', () => { + const result = runMatch('abc', '\\b', 'g'); // matches at positions 0, 3 + assert.equal(result.ok, true); + assert.ok(result.count <= 4, `expected a bounded count, got ${result.count}`); +}); + +test('runMatch returns an error for an invalid regex', () => { + const result = runMatch('x', '(', 'g'); + assert.equal(result.ok, false); + assert.ok(typeof result.error === 'string' && result.error.length > 0); +}); + +test('runReplace applies a numbered-group replacement', () => { + const result = runReplace('a1 b2', '(\\d)', 'g', '[$1]'); + assert.equal(result.ok, true); + assert.equal(result.result, 'a[1] b[2]'); +}); + +test('runReplace applies a named-group replacement', () => { + const result = runReplace('y2024 y2025', '(?\\d{4})', 'g', '[$]'); + assert.equal(result.ok, true); + assert.equal(result.result, 'y[2024] y[2025]'); +}); + +test('runReplace reports an invalid regex', () => { + const result = runReplace('x', '(', '', 'y'); + assert.equal(result.ok, false); + assert.ok(typeof result.error === 'string'); +}); diff --git a/plugins/lym308758/regex-lab/tests/server.test.mjs b/plugins/lym308758/regex-lab/tests/server.test.mjs new file mode 100644 index 0000000..ce570a3 --- /dev/null +++ b/plugins/lym308758/regex-lab/tests/server.test.mjs @@ -0,0 +1,92 @@ +// @ts-check +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { fileURLToPath } from 'node:url'; + +import { createHandler, start } from '../miniapp/node/server.mjs'; + +function startServer(handler) { + return new Promise((resolve) => { + const server = createServer(handler); + server.listen(0, '127.0.0.1', () => resolve(server)); + }); +} + +async function postJson(server, path, body) { + const res = await fetch(`http://127.0.0.1:${server.address().port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, data: await res.json() }; +} + +test('POST /api/match returns matches with named and numbered groups', async () => { + const server = await startServer(createHandler({ html: '' })); + const { status, data } = await postJson(server, '/api/match', { + pattern: '(?\\d{4})-(\\d{2})', + flags: 'g', + text: '2024-01 and 2025-12', + }); + assert.equal(status, 200); + assert.equal(data.ok, true); + assert.equal(data.count, 2); + assert.equal(data.matches[0].namedGroups.year, '2024'); + assert.equal(data.matches[0].groups[1].value, '01'); + server.close(); +}); + +test('POST /api/match reports an invalid regex', async () => { + const server = await startServer(createHandler({ html: '' })); + const { status, data } = await postJson(server, '/api/match', { pattern: '(', flags: '', text: 'x' }); + assert.equal(status, 200); + assert.equal(data.ok, false); + assert.ok(typeof data.error === 'string' && data.error.length > 0); + server.close(); +}); + +test('POST /api/replace applies a named-group replacement', async () => { + const server = await startServer(createHandler({ html: '' })); + const { status, data } = await postJson(server, '/api/replace', { + pattern: '(?\\d{4})', + flags: 'g', + text: 'y2024 y2025', + replacement: '[$]', + }); + assert.equal(status, 200); + assert.equal(data.ok, true); + assert.equal(data.result, 'y[2024] y[2025]'); + server.close(); +}); + +test('GET /dashboard serves the html body', async () => { + const server = await startServer(createHandler({ html: 'hi' })); + const res = await fetch(`http://127.0.0.1:${server.address().port}/dashboard`); + assert.equal(res.status, 200); + assert.equal(await res.text(), 'hi'); + server.close(); +}); + +test('unknown route returns 404 json', async () => { + const server = await startServer(createHandler({ html: '' })); + const res = await fetch(`http://127.0.0.1:${server.address().port}/nope`); + assert.equal(res.status, 404); + assert.equal((await res.json()).error, 'not_found'); + server.close(); +}); + +test('start() reads the page, logs readiness, and disposes without error', async () => { + const pluginRoot = fileURLToPath(new URL('..', import.meta.url)); + let logged = ''; + const controller = new AbortController(); + const lifecycle = await start({ + pluginRoot, + listen: { host: '127.0.0.1', port: 0 }, + logger: { info: (message) => { logged = message; } }, + signal: controller.signal, + }); + assert.ok(typeof lifecycle.dispose === 'function'); + assert.equal(logged, 'miniapp.runtime.listening'); + await assert.doesNotReject(lifecycle.dispose()); +});