diff --git a/packages/extension/src/amicode_service/widgets.ts b/packages/extension/src/amicode_service/widgets.ts index 650b263e..685c5560 100644 Binary files a/packages/extension/src/amicode_service/widgets.ts and b/packages/extension/src/amicode_service/widgets.ts differ diff --git a/packages/extension/src/amicode_service/widgets_src/campaign-digest.ts b/packages/extension/src/amicode_service/widgets_src/campaign-digest.ts new file mode 100644 index 00000000..73b0c349 --- /dev/null +++ b/packages/extension/src/amicode_service/widgets_src/campaign-digest.ts @@ -0,0 +1,152 @@ +// AMICODE built-in widget: CAMPAIGN DIGEST — the home-dashboard digest over +// the campaign routes (#678, data contract #658/#662): GET /amicode/campaigns +// (newest-first list) + GET /amicode/campaign?slug= (the ledger's parsed +// sections). The tile renders the active campaign's one-line objective, the +// newest verdict-table entries as compact chips, and the §4 blocked section +// as the needs-you line. Mechanical projection only — the tile renders what +// the routes return, no ledger-markdown parsing beyond display compression +// of the status cells. Any fetch failure or a campaign-less ledger dir +// renders nothing (height-0 empty-state, the jump-back-in discipline). + +export const manifestToml = ` +id = "campaign-digest" +name = "Campaign digest" +version = "1.0.0" +description = "Live digest of your active research campaign — objective, verdicts, needs-you" +size = "tile" +height = 150 +` + +export const widgetJs = ` +export default { + mount: function (el, amico) { + var esc = function (s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + } + // Verdict chips: the §2 table's data rows (row 0 is the header per the + // route contract). A chip is the first cell (the slice/unit) plus the + // status cell's leading token — display compression of the raw cell, + // not parsing: ledgers write '**DONE** — PR #17 merged …' and the chip + // shows 'DONE'. + var MAX_VERDICT_CHIPS = 3 + var showEmpty = function () { + el.innerHTML = '' + } + var statusToken = function (cell) { + return String(cell || '') + .split('**').join('') + .split('\\u2014')[0] + .split('\\u2013')[0] + .trim() + .split(/\\s+/) + .slice(0, 2) + .join(' ') + } + var chipTone = function (cell) { + var s = String(cell || '').toUpperCase() + if (s.indexOf('DONE') >= 0 || s.indexOf('MERGED') >= 0 || s.indexOf('PASS') >= 0) return 'var(--amc-success)' + if (s.indexOf('BLOCK') >= 0 || s.indexOf('FAIL') >= 0 || s.indexOf('STUCK') >= 0) return 'var(--amc-danger)' + return 'var(--amc-accent)' + } + // Pick the newest campaign whose status is ACTIVE (case-insensitive — + // ledgers write 'ACTIVE' in the frontmatter), falling back to the newest + // overall: a finished campaign still deserves a tile until the next one + // starts. The list is newest-first per the route contract, so the first + // match wins. + var pickCampaign = function (campaigns) { + if (!campaigns || campaigns.length === 0) return null + for (var i = 0; i < campaigns.length; i++) { + var status = String((campaigns[i] && campaigns[i].status) || '').toLowerCase() + if (status === 'active') return campaigns[i] + } + return campaigns[0] + } + // First non-empty line of a section body, bullet + bold markers + // stripped (the same mechanical projection the list route applies to + // the objective line). + var firstLine = function (text) { + var lines = String(text || '').split('\\n') + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim().replace(/^[-*]\\s+/, '').split('**').join('') + if (line !== '') return line + } + return '' + } + var renderCard = function (entry, detail) { + var slug = entry.slug + var eyebrow = entry.campaign || 'Campaign digest' + var objective = String(entry.objective || '') + var blocked = firstLine(detail.blocked) + var chips = '' + var rows = (detail.verdicts || []).slice(1, 1 + MAX_VERDICT_CHIPS) + for (var i = 0; i < rows.length; i++) { + var row = rows[i] + if (!row || row.length < 2) continue + var label = statusToken(row[row.length - 1]) + if (label === '') continue + chips += + '' + + esc(row[0]) + + ' \\u00b7 ' + + esc(label) + + '' + } + el.innerHTML = + '
' + + '
' + + esc(eyebrow) + + '
' + + (objective !== '' + ? '
' + + esc(objective) + + '
' + : '') + + (chips !== '' ? '
' + chips + '
' : '') + + '
' + + (blocked !== '' ? '\\u26a0 needs you \\u2014 ' + esc(blocked) : 'nothing blocked') + + '
' + + '
' + var card = el.querySelector('[data-card]') + if (card) + card.onclick = function () { + amico.prompt('Open the campaign ' + slug) + } + } + var epoch = 0 + var render = function () { + var mine = ++epoch + amico + .fetch('/amicode/campaigns') + .then(function (data) { + if (mine !== epoch) return undefined + if (!data || data.ok === false) return showEmpty() + var picked = pickCampaign(data.campaigns) + if (!picked || !picked.slug) return showEmpty() + return amico + .fetch('/amicode/campaign?slug=' + encodeURIComponent(picked.slug)) + .then(function (detail) { + if (mine !== epoch) return undefined + if (!detail || detail.ok === false || !detail.campaign) return showEmpty() + renderCard(picked, detail.campaign) + }) + }) + .catch(function () { + if (mine === epoch) showEmpty() + }) + } + render() + // Re-render on host config/theme pushes (the jump-back-in pattern); + // the epoch guard drops a stale fetch's late write. + amico.onConfig(render) + amico.onTheme(render) + }, +} +` diff --git a/packages/extension/test/amicode_service_contract.test.ts b/packages/extension/test/amicode_service_contract.test.ts index 48f010a8..ad92caa9 100644 --- a/packages/extension/test/amicode_service_contract.test.ts +++ b/packages/extension/test/amicode_service_contract.test.ts @@ -124,6 +124,30 @@ describe("amicode service — golden-fixture parity with the fork", () => { return obj; }; + /** amicode#678: the port ships a campaign-digest BUILT-IN widget the fork + * pin predates (recorded at v1.18.10-amicode.18 with jump-back-in only). + * Dropped from BOTH sides so parity stays comparable across the next pin + * bump; the widget itself is unit-tested in widgets_registry.test.ts. + * Touches the three routes that project the registry: /amicode/widgets + * (top-level `widgets`) and the dashboard GET/POST (the merge appends + * every built-in as a default tile under `dashboard.widget`). */ + const normalizeBuiltinWidgetDrift = (obj: any): any => { + if (obj && typeof obj === "object") { + const out = { ...obj }; + if (Array.isArray(out.widgets)) { + out.widgets = out.widgets.filter((w: any) => !(w && w.id === "campaign-digest")); + } + if (out.dashboard && typeof out.dashboard === "object" && Array.isArray(out.dashboard.widget)) { + out.dashboard = { + ...out.dashboard, + widget: out.dashboard.widget.filter((w: any) => !(w && w.id === "campaign-digest")), + }; + } + return out; + } + return obj; + }; + const normalizeWallClock = (obj: any): any => { if ( obj && @@ -206,7 +230,9 @@ describe("amicode service — golden-fixture parity with the fork", () => { // JSON routes: deep-equal on parsed bodies (key order is the port's // business; structure and values are the contract). const canon = (o: any, seededAt: number) => - normalizePortExtensions(normalizePostPinDrift(normalizeListOrder(normalizeWallClock(normalizeFreshTimestamps(o, seededAt))))); + normalizeBuiltinWidgetDrift( + normalizePortExtensions(normalizePostPinDrift(normalizeListOrder(normalizeWallClock(normalizeFreshTimestamps(o, seededAt))))), + ); expect(canon(JSON.parse(received), testSeededAt)).toEqual(canon(JSON.parse(expected), meta.seededAt)); } else { // Non-JSON routes (the served widget frame): byte-exact after sandbox diff --git a/packages/extension/test/widgets_registry.test.ts b/packages/extension/test/widgets_registry.test.ts new file mode 100644 index 00000000..a7662a49 --- /dev/null +++ b/packages/extension/test/widgets_registry.test.ts @@ -0,0 +1,124 @@ +// AMICODE (issue #678): the campaign-digest built-in widget — registration +// and route-contract pins. The widget's JS is a string-rendered module (the +// jump-back-in pattern), so behavioral assertions are string-level on the +// registry entry + the route bodies; the iframe itself is exercised by the +// runtime's own contract, not here. +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadRegistry, widgetsResponse, widgetCodeResponse } from "../src/amicode_service/widgets"; +import { manifestToml, widgetJs } from "../src/amicode_service/widgets_src/campaign-digest"; + +const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +describe("campaign-digest built-in widget (issue #678)", () => { + let savedWidgetsDir: string | undefined; + let userDir: string; + + beforeAll(() => { + // Hermetic registry: point the user-widgets root at an empty temp dir so + // host state (~/.amico/widgets) can't leak entries into the assertions. + savedWidgetsDir = process.env.AMICODE_WIDGETS_DIR; + userDir = mkdtempSync(join(tmpdir(), "amico-widgets-")); + process.env.AMICODE_WIDGETS_DIR = userDir; + }); + afterAll(() => { + if (savedWidgetsDir === undefined) delete process.env.AMICODE_WIDGETS_DIR; + else process.env.AMICODE_WIDGETS_DIR = savedWidgetsDir; + rmSync(userDir, { recursive: true, force: true }); + }); + + // AC1 — registration, manifest validity, default tile order + it("registers as a built-in after jump-back-in, with a valid tile manifest", () => { + const { widgets, warnings } = loadRegistry(); + const jump = widgets.findIndex((w) => w.manifest.id === "jump-back-in"); + const digest = widgets.findIndex((w) => w.manifest.id === "campaign-digest"); + expect(jump).toBeGreaterThanOrEqual(0); + expect(digest).toBeGreaterThan(jump); + const entry = widgets[digest]; + expect(entry.builtin).toBe(true); + expect(entry.manifest.size).toBe("tile"); + expect(KEBAB.test(entry.manifest.id)).toBe(true); + expect(warnings.some((w) => w.id === "campaign-digest")).toBe(false); + }); + + it("manifest TOML carries the digest identity (parses into the registry)", () => { + expect(manifestToml).toContain('id = "campaign-digest"'); + expect(manifestToml).toContain('size = "tile"'); + expect(manifestToml).toMatch(/height = \d+/); + }); + + // AC2 — the route contract, string-level on the widget source + it("fetches the campaigns list then the campaign detail for the picked slug", () => { + expect(widgetJs).toContain("'/amicode/campaigns'"); + expect(widgetJs).toContain("'/amicode/campaign?slug='"); + expect(widgetJs).toContain("encodeURIComponent("); + }); + + it("picks the newest ACTIVE campaign with a newest-overall fallback", () => { + expect(widgetJs).toContain("pickCampaign"); + expect(widgetJs).toContain("'active'"); // the ACTIVE status match + }); + + it("renders up to 3 verdict chips from the detail's verdict rows", () => { + expect(widgetJs).toContain("MAX_VERDICT_CHIPS = 3"); + expect(widgetJs).toContain("verdicts"); + }); + + it("renders the needs-you line from the blocked section", () => { + expect(widgetJs).toContain("needs you"); + expect(widgetJs).toContain("nothing blocked"); + }); + + it("degrades to the empty state when there are no campaigns", () => { + expect(widgetJs).toContain("showEmpty"); + expect(widgetJs).toContain("el.innerHTML = ''"); + }); + + // AC3 — fetch failure → empty state, never an error dump + it("catches fetch failures into the empty state", () => { + expect(widgetJs).toContain(".catch("); + // the catch path and the no-campaign path share the same empty state + expect(widgetJs).toContain("showEmpty"); + }); + + // AC4 — click composes into chat with the slug + it("clicks through to amico.prompt with the campaign slug", () => { + expect(widgetJs).toContain("amico.prompt("); + expect(widgetJs).toContain("'Open the campaign '"); + }); + + // AC5 — esc discipline + theme tokens only + it("escapes all interpolated HTML and themes via --amc-* custom properties only", () => { + expect(widgetJs).toContain("esc("); + expect(widgetJs).toContain("&"); + expect(widgetJs).toContain("<"); + expect(widgetJs).toContain(">"); + expect(widgetJs).toContain("""); + expect(widgetJs).toContain("var(--amc-"); + expect(widgetJs).not.toMatch(/#[0-9a-fA-F]{3,8}\b/); // no raw hex colors + expect(widgetJs).not.toContain("rgb("); + expect(widgetJs).not.toContain("localStorage"); // opaque origin — it throws + }); + + // AC6 — registry/content-hash machinery consistency + it("hashes the entry stably across registry loads (idempotence)", () => { + const a = loadRegistry().widgets.find((w) => w.manifest.id === "campaign-digest"); + const b = loadRegistry().widgets.find((w) => w.manifest.id === "campaign-digest"); + expect(a).toBeDefined(); + expect(b).toBeDefined(); + expect(a!.hash).toMatch(/^[0-9a-f]{16}$/); + expect(a!.hash).toBe(b!.hash); + }); + + it("serves the widget through the /amicode/widgets + widget-code route bodies", () => { + const list = JSON.parse(widgetsResponse()); + expect(list.ok).toBe(true); + expect(list.widgets.map((w: any) => w.id)).toContain("campaign-digest"); + const code = JSON.parse(widgetCodeResponse("campaign-digest")); + expect(code.ok).toBe(true); + expect(code.hash).toMatch(/^[0-9a-f]{16}$/); + expect(code.code).toBe(widgetJs); // the served code IS the source string + }); +});