diff --git a/.github/workflows/cell-manifest-diff.yml b/.github/workflows/cell-manifest-diff.yml new file mode 100644 index 0000000..d7f7e39 --- /dev/null +++ b/.github/workflows/cell-manifest-diff.yml @@ -0,0 +1,62 @@ +# SC-010:cell manifest 只读守门(AI-robust Hard)。 +# 跑 `pnpm cell-manifest` 后断言 packages/devboard/src/manifest/ 无 diff —— 手改生成物即红。 +name: cell-manifest-diff + +on: + pull_request: + push: + branches: [develop] + +jobs: + cell-manifest-readonly: + runs-on: ubuntu-latest + steps: + - name: Checkout gocell-web + uses: actions/checkout@v4 + with: + path: gocell-web + + # 后端 cells 源(公开仓库 ghbvf/gocell),checkout 为 gocell-web 的同级目录 + # (cell-manifest 默认读 ../gocell/cells)。 + - name: Checkout gocell (backend cells) + uses: actions/checkout@v4 + with: + repository: ghbvf/gocell + path: gocell + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.4.0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: gocell-web/pnpm-lock.yaml + + - name: Install (frozen lockfile) + run: pnpm install --frozen-lockfile + working-directory: gocell-web + + - name: Regenerate cell manifest + run: pnpm cell-manifest + working-directory: gocell-web + + # 守门:cell manifest 产物须与提交完全一致。 + # 使用 `git status --porcelain` 同时覆盖 修改/新增/删除 三种漂移。 + - name: Assert cell manifest is read-only (no manual edits / uncommitted) + run: | + if [ -n "$(git status --porcelain packages/devboard/src/manifest/)" ]; then + echo "::error::packages/devboard/src/manifest/ 与 cell-manifest 输出不一致(含未提交的新增/修改/删除)。请本地跑 \`pnpm cell-manifest\` 后提交。" + git status --porcelain packages/devboard/src/manifest/ + git diff packages/devboard/src/manifest/ + exit 1 + fi + working-directory: gocell-web + + # 守门第二段:生成物未漂移后,确认下游消费端类型未被破坏。 + - name: Typecheck downstream consumers + run: pnpm -w typecheck + working-directory: gocell-web diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a735bed..3fea0e3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,13 @@ jobs: - name: Unit tests (with coverage) run: pnpm test:coverage + # Boundary self-check (ESLint Node API) runs uninstrumented: under v8 + # coverage the type-aware lint engine is ~12x slower and starves the + # vitest reporter RPC (onTaskUpdate timeout). Coverage adds no value for + # this enforcement spec, so it runs as its own fast step. + - name: Boundary self-check (no coverage) + run: pnpm test:boundary + - name: Build web run: pnpm -F @gocell/web build diff --git a/.prettierignore b/.prettierignore index 0d60783..8eb00e5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ # Codegen 生成物(只读,不 format) packages/contracts/src/ +packages/devboard/src/manifest/cells.generated.ts # 构建产物 **/dist/ diff --git a/apps/web/package.json b/apps/web/package.json index 4297ce2..743ad36 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@gocell/access": "workspace:*", "@gocell/audit": "workspace:*", "@gocell/config": "workspace:*", + "@gocell/devboard": "workspace:*", "@gocell/request": "workspace:*", "vue-i18n": "catalog:" }, diff --git a/apps/web/src/router/index.spec.ts b/apps/web/src/router/index.spec.ts index 09655bf..2f4a190 100644 --- a/apps/web/src/router/index.spec.ts +++ b/apps/web/src/router/index.spec.ts @@ -68,4 +68,23 @@ describe('router layout fork', () => { expect(m.meta.requiredAction).toBe('read') expect(m.meta.requiredResource).toBe('flag') }) + + it('renders /cells nested under the shell, behind the auth + PDP gates (Batch 5)', () => { + const m = router.resolve('/cells') + expect(m.name).toBe('cells') + expect(m.matched.length).toBeGreaterThanOrEqual(2) + expect(m.meta.requiresAuth).toBe(true) + expect(m.meta.requiredAction).toBe('read') + expect(m.meta.requiredResource).toBe('cell') + }) + + it('renders /cells/:id detail behind the auth + PDP gates, carrying the id param (Batch 5)', () => { + const m = router.resolve('/cells/accesscore') + expect(m.name).toBe('cell-detail') + expect(m.params.id).toBe('accesscore') + expect(m.matched.length).toBeGreaterThanOrEqual(2) + expect(m.meta.requiresAuth).toBe(true) + expect(m.meta.requiredAction).toBe('read') + expect(m.meta.requiredResource).toBe('cell') + }) }) diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 8475c9e..17226f6 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -87,6 +87,24 @@ const routes: RouteRecordRaw[] = [ component: () => import('@gocell/config/views/flags'), meta: { requiresAuth: true, requiredAction: 'read', requiredResource: 'flag' }, }, + { + // Operate · Cells list (Batch 5). PDP-gated read on `cell` (fail-closed + // until /access/decide ships, BR-004). Data is the static build-time + // manifest derived from cell.yaml — no backend endpoint required. + path: 'cells', + name: 'cells', + component: () => import('@gocell/devboard/views/cells-list'), + meta: { requiresAuth: true, requiredAction: 'read', requiredResource: 'cell' }, + }, + { + // Operate · Cell detail (Batch 5). Same PDP gate; 12-tab inspector with + // the active tab carried in the `?tab=` query param. Own async chunk via + // the @gocell/devboard/views/cell-detail subpath export. + path: 'cells/:id', + name: 'cell-detail', + component: () => import('@gocell/devboard/views/cell-detail'), + meta: { requiresAuth: true, requiredAction: 'read', requiredResource: 'cell' }, + }, ], }, ] diff --git a/eslint.config.js b/eslint.config.js index 3c738b0..d8db3d0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -90,6 +90,7 @@ export default tseslint.config( { ignores: [ 'packages/contracts/src/**', // codegen-生成物,不 lint + 'packages/devboard/src/manifest/cells.generated.ts', // cell-manifest 生成物,不 lint '**/dist/**', '**/coverage/**', 'pnpm-lock.yaml', @@ -396,6 +397,16 @@ export default tseslint.config( }, }, + // ── tools/cell-manifest vitest.config.ts ────────────────────────────────── + { + files: ['tools/cell-manifest/vitest.config.ts'], + languageOptions: { + parserOptions: { + project: path.resolve(__dirname, 'tools/cell-manifest/tsconfig.json'), + }, + }, + }, + // ── Prettier: 放最后关闭格式类规则,交给 prettier ───────────────────────── configPrettier, ) diff --git a/package.json b/package.json index c650bce..d0c933d 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,11 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "vitest run", - "test:coverage": "vitest run --coverage", + "test:coverage": "vitest run --coverage --project '!root'", + "test:boundary": "vitest run --project root", "test:e2e": "playwright test", "codegen": "pnpm -F @gocell/codegen generate", + "cell-manifest": "pnpm -F @gocell/cell-manifest generate", "prepare": "husky" }, "lint-staged": { diff --git a/packages/core/src/i18n/messages/en-US.ts b/packages/core/src/i18n/messages/en-US.ts index a281be5..f8af775 100644 --- a/packages/core/src/i18n/messages/en-US.ts +++ b/packages/core/src/i18n/messages/en-US.ts @@ -656,6 +656,181 @@ const enUS = { }, }, }, + cells: { + list: { + title: 'Cells', + subtitle: + 'Core objects. Every contract, dependency, task, config entry, and audit record belongs to a cell.', + searchLabel: 'Search cells', + searchPlaceholder: 'Search by id or name…', + domainAll: 'All', + domainFilterLabel: 'Filter by domain', + count: '{count} cells', + empty: 'No matching cells', + openCell: 'Open {name} details', + col: { + cell: 'Cell', + domain: 'Domain', + type: 'Type', + tier: 'Consistency', + owner: 'Owner team', + durability: 'Durability', + slices: 'Slices', + produces: 'Produces', + consumes: 'Consumes', + requires: 'Requires', + }, + }, + detail: { + back: 'Back to Cells', + notFound: 'Cell not found', + notFoundDesc: 'No cell with id "{id}" exists in the manifest.', + tablistLabel: 'Cell detail sections', + }, + tabs: { + overview: 'Overview', + interfaces: 'Interfaces', + wiring: 'Wiring', + inventory: 'Inventory', + slices: 'Slices', + contracts: 'Contracts', + dependencies: 'Dependencies', + tasks: 'Tasks', + configuration: 'Configuration', + audit: 'Audit', + groups: 'Groups', + ai: 'AI', + }, + overview: { + identity: 'Identity', + id: 'ID', + name: 'Name', + domain: 'Domain', + type: 'Type', + consistency: 'Consistency level', + durability: 'Durability', + owner: 'Owner team', + ownerRole: 'Role', + goStruct: 'Go struct', + schema: 'Primary schema', + lifecycle: 'Lifecycle', + health: 'Health', + runtime: 'Runtime', + }, + interfaces: { + ispTitle: 'ISP sub-interfaces', + ispHint: 'Each cell exposes four isolated sub-interfaces (Interface Segregation Principle).', + identity: 'Identity', + identityDesc: 'Identity and ownership: id, owner team, Go struct.', + lifecycle: 'Lifecycle', + lifecycleDesc: 'Lifecycle, consistency level, and infrastructure dependencies.', + status: 'Status', + statusDesc: 'Health and runtime state (requires BR-001 health endpoint).', + inventory: 'Inventory', + inventoryDesc: 'Slices, contracts, and smoke tests inventory.', + produces: 'Produced contracts', + consumes: 'Consumed contracts', + contract: 'Contract', + role: 'Role', + noProduces: 'This cell does not produce any contracts.', + noConsumes: 'This cell does not consume any contracts.', + fields: { + id: 'ID', + owner: 'Owner team', + goStruct: 'Go struct', + lifecycle: 'Lifecycle', + consistency: 'Consistency level', + }, + }, + wiring: { + title: 'Infrastructure wiring', + requires: 'Required infrastructure', + l0deps: 'L0 dependencies', + infraToCell: 'Infrastructure → Cell', + none: 'No declared dependencies.', + hint: 'A cell seals its dependencies at construction time; the list below reflects runtime deps declared in cell.yaml.', + }, + inventory: { + title: 'Inventory', + metric: 'Metric', + count: 'Count', + slices: 'Slices', + produces: 'Produced contracts', + consumes: 'Consumed contracts', + requires: 'Infrastructure dependencies', + smokeTests: 'Smoke tests', + contracts: 'Total contracts', + }, + slices: { + title: 'Slices', + slice: 'Slice', + consistency: 'Consistency', + contracts: 'Contracts', + waivers: 'Waivers', + waiver: 'Waiver', + waiverExpires: 'Expires {date}', + none: 'This cell has no slices.', + }, + contracts: { + title: 'Contracts', + produces: 'Produces', + consumes: 'Consumes', + contract: 'Contract', + role: 'Role', + none: 'No contracts.', + }, + dependencies: { + title: 'Dependencies', + dependsOn: 'Depends on', + requiredBy: 'Required by', + infra: 'Infrastructure', + none: 'No cross-cell dependencies.', + hint: 'Dependencies are derived from the contract consumption relationships of slices.', + }, + roles: { + serve: 'Serves', + call: 'Calls', + publish: 'Publishes', + subscribe: 'Subscribes', + }, + badge: { + durable: 'Durable', + demo: 'Demo', + durableHint: 'Durable cell: state is persisted to the database.', + demoHint: 'Demo cell: state is not persisted.', + }, + unavailable: { + label: '—', + title: 'Data unavailable', + runtime: + 'Runtime metrics require a backend health endpoint (BR-001), which is not yet available.', + tasks: 'Task data source is not yet connected.', + groups: 'Smart grouping is a reserved capability (Batch 6).', + ai: 'AI capabilities are out of scope for this wave.', + }, + configuration: { + title: 'Configuration', + tableLabel: 'Configuration entries', + loading: 'Loading configuration…', + error: 'Failed to load configuration.', + empty: 'No configuration entries for this cell.', + key: 'Key', + value: 'Value', + version: 'Version', + openFull: 'Open in configuration page', + }, + audit: { + title: 'Audit', + tableLabel: 'Audit entries', + loading: 'Loading audit records…', + error: 'Failed to load audit records.', + empty: 'No audit records.', + action: 'Action', + actor: 'Actor', + time: 'Time', + openFull: 'Open in audit page', + }, + }, errors: { unknown: 'An unknown error occurred. Please try again.', network: 'Network connection failed. Please check your connection.', diff --git a/packages/core/src/i18n/messages/zh-CN.ts b/packages/core/src/i18n/messages/zh-CN.ts index 70e8c00..4debd4d 100644 --- a/packages/core/src/i18n/messages/zh-CN.ts +++ b/packages/core/src/i18n/messages/zh-CN.ts @@ -645,6 +645,179 @@ const zhCN = { }, }, }, + cells: { + list: { + title: 'Cells', + subtitle: '核心对象。每个契约、依赖、任务、配置与审计记录都挂在 cell 上。', + searchLabel: '搜索 cell', + searchPlaceholder: '按 id 或名称搜索…', + domainAll: '全部', + domainFilterLabel: '按域筛选', + count: '{count} 个 cell', + empty: '没有匹配的 cell', + openCell: '打开 {name} 详情', + col: { + cell: 'Cell', + domain: '域', + type: '类型', + tier: '一致性', + owner: '负责团队', + durability: '持久性', + slices: 'Slices', + produces: '产出', + consumes: '消费', + requires: '依赖', + }, + }, + detail: { + back: '返回 Cells', + notFound: '未找到 cell', + notFoundDesc: 'manifest 中不存在 id 为 "{id}" 的 cell。', + tablistLabel: 'Cell 详情分区', + }, + tabs: { + overview: '概览', + interfaces: '接口', + wiring: '装配', + inventory: '清单', + slices: 'Slices', + contracts: '契约', + dependencies: '依赖', + tasks: '任务', + configuration: '配置', + audit: '审计', + groups: '分组', + ai: 'AI', + }, + overview: { + identity: '身份', + id: 'ID', + name: '名称', + domain: '域', + type: '类型', + consistency: '一致性级别', + durability: '持久性', + owner: '负责团队', + ownerRole: '角色', + goStruct: 'Go 结构体', + schema: '主 schema', + lifecycle: '生命周期', + health: '健康度', + runtime: '运行时', + }, + interfaces: { + ispTitle: 'ISP 子接口', + ispHint: '每个 cell 对外暴露四个隔离的子接口(接口隔离原则)。', + identity: 'Identity', + identityDesc: '身份与归属:id、负责团队、Go 结构体。', + lifecycle: 'Lifecycle', + lifecycleDesc: '生命周期、一致性级别与依赖的基础设施。', + status: 'Status', + statusDesc: '健康与运行时状态(需 BR-001 健康端点)。', + inventory: 'Inventory', + inventoryDesc: 'slices、契约与冒烟测试清单。', + produces: '产出契约', + consumes: '消费契约', + contract: '契约', + role: '角色', + noProduces: '该 cell 不产出任何契约。', + noConsumes: '该 cell 不消费任何契约。', + fields: { + id: 'ID', + owner: '负责团队', + goStruct: 'Go 结构体', + lifecycle: '生命周期', + consistency: '一致性级别', + }, + }, + wiring: { + title: '基础设施装配', + requires: '依赖基础设施', + l0deps: 'L0 依赖', + infraToCell: '基础设施 → Cell', + none: '无声明依赖。', + hint: 'cell 在构造期密封其依赖;下列为 cell.yaml 声明的运行时依赖。', + }, + inventory: { + title: '清单', + metric: '项目', + count: '数量', + slices: 'Slices', + produces: '产出契约', + consumes: '消费契约', + requires: '基础设施依赖', + smokeTests: '冒烟测试', + contracts: '契约总数', + }, + slices: { + title: 'Slices', + slice: 'Slice', + consistency: '一致性', + contracts: '契约', + waivers: '豁免', + waiver: '豁免', + waiverExpires: '到期 {date}', + none: '该 cell 无 slice。', + }, + contracts: { + title: '契约', + produces: '产出', + consumes: '消费', + contract: '契约', + role: '角色', + none: '无契约。', + }, + dependencies: { + title: '依赖', + dependsOn: '依赖的 cell', + requiredBy: '被依赖于', + infra: '基础设施', + none: '无 cell 间依赖。', + hint: '依赖由 slice 的契约消费关系派生。', + }, + roles: { + serve: '提供', + call: '调用', + publish: '发布', + subscribe: '订阅', + }, + badge: { + durable: 'Durable', + demo: 'Demo', + durableHint: '持久 cell:状态落库。', + demoHint: '演示 cell:状态非持久。', + }, + unavailable: { + label: '—', + title: '数据暂不可用', + runtime: '运行时指标需后端健康端点(BR-001),当前不可用。', + tasks: '任务数据源尚未接入。', + groups: '智能分组为预留能力(Batch 6)。', + ai: 'AI 能力为后续 Wave 范围。', + }, + configuration: { + title: '配置', + tableLabel: '配置项', + loading: '加载配置中…', + error: '加载配置失败。', + empty: '该 cell 暂无配置项。', + key: '键', + value: '值', + version: '版本', + openFull: '在配置页打开', + }, + audit: { + title: '审计', + tableLabel: '审计记录', + loading: '加载审计记录中…', + error: '加载审计记录失败。', + empty: '暂无审计记录。', + action: '操作', + actor: '操作者', + time: '时间', + openFull: '在审计页打开', + }, + }, errors: { unknown: '发生未知错误,请稍后重试', network: '网络连接失败,请检查网络后重试', diff --git a/packages/core/src/styles/tokens.css b/packages/core/src/styles/tokens.css index a9f3c75..c225636 100644 --- a/packages/core/src/styles/tokens.css +++ b/packages/core/src/styles/tokens.css @@ -9,6 +9,14 @@ --font-mono: 'Geist Mono', 'JetBrains Mono', ui-monospace, monospace; --font-serif: 'Instrument Serif', Georgia, serif; + /* font-size ladder (theme-invariant) */ + --text-xs: 11px; + --text-sm: 12px; + --text-base: 13px; + --text-lg: 14px; + --text-xl: 18px; + --text-2xl: 20px; + /* light palette — nearly white with subtle cool tint */ --bg: oklch(0.995 0.002 270); --bg-sunken: oklch(0.975 0.003 270); diff --git a/packages/devboard/README.md b/packages/devboard/README.md index 4b6c954..4049fd0 100644 --- a/packages/devboard/README.md +++ b/packages/devboard/README.md @@ -1,15 +1,39 @@ # @gocell/devboard -> 开发者平台聚合视图:Cells / Groups / Coverage / Contracts / Deps +> 开发者平台聚合视图:Cells / Groups / Coverage / Contracts / Deps(Batch 5/6) +> +> **对应后端 cell**:无(聚合派生视图)。Cells 数据由构建期从后端 `../gocell/cells/*/cell.yaml` + `slices/*/slice.yaml` 派生(见下)。 -- **对应后端 cell**:无(聚合派生视图) -- **对外 exports**:`.` → `src/index.ts`(唯一收口;未列出路径外部不可访问) -- **依赖 contract**:(待 Batch 实施时补全) +## 对外 exports + +- `.` → `src/index.ts`:manifest 类型、`CELL_MANIFEST`、`useCellsStore`、共享组件(`CellDurabilityBadge` / `UnavailablePanel`) +- `./views/cells-list` → `CellsListView.vue`(`/cells` 列表页,T503) +- `./views/cell-detail` → `CellDetailView.vue`(`/cells/:id` 12-tab 详情,T504) + +未列出的路径外部不可访问(`package.json#exports` 唯一收口)。 + +## Cell manifest 派生(T502,AI-robust Hard) + +`tools/cell-manifest/` 镜像 `tools/codegen` 模式:构建期读后端 `cell.yaml` + `slice.yaml` → 派生 `src/manifest/cells.generated.ts`(`/* eslint-disable */` + DO-NOT-EDIT banner,prettier-ignored)。 + +- 单源派生 + CI `git diff --exit-code`(`.github/workflows/cell-manifest-diff.yml`)守门,业务包手改生成物即红 → 违反不可表达(Hard)。 +- 本地重跑:`GOCELL_CELLS_DIR=../gocell/cells pnpm cell-manifest`(CI 把 `ghbvf/gocell` checkout 为同级目录,默认路径生效)。 +- `slice.yaml` 的 `contractUsages[].role`:`serve`/`publish` → cell **produces**;`call`/`subscribe` → **consumes**;跨 cell 的 consume→produce 解析出 `dependsOnCells` / `requiredByCells`。 +- **不可派生字段**(运行时 QPS/p95/健康分、tasks、SLOC、version、oncall)→ 显式降级为 "—" / `UnavailablePanel`,**绝不伪造**。需后端健康端点(BR-001)才补。 + +## 依赖的 contract + +- `http.audit.list.v1`(`HttpAuditListV1Response`)→ Cell detail · Audit tab(经 `@gocell/request` 直调 `/api/v1/audit/`,**不** import `@gocell/audit`) +- `http.config.list.v1`(`HttpConfigListV1Response`)→ Cell detail · Configuration tab(直调 `/api/v1/config/`,**不** import `@gocell/config`) ## 边界 -依赖规则见仓库根 `CLAUDE.md` §依赖规则。跨包仅经 `@gocell/contracts` 类型 + `@gocell/request` client,禁深路径 import。 +依赖规则见仓库根 `CLAUDE.md` §依赖规则 / `.claude/rules/gocellweb/package-boundaries.md`。跨包仅经 `@gocell/contracts` 类型 + `@gocell/request` client,禁深路径 import。 + +**已批准设计性例外**:*允许*依赖 `@gocell/access`(消费其 PDP client 能力)——因 devboard 所有页面都依赖 PDP;其他业务包不享有此例外。当前 Cells 页为只读、页面级 PDP 由路由守卫执行,故 **暂未**实际 import `@gocell/access`(需要 in-component 决策时再加 dep)。**禁**直接 import `@gocell/audit` / `@gocell/config`(Audit/Config tab 经 `@gocell/request` + `@gocell/contracts` 复用同一后端端点,T505 边界洁净方案)。 + +**`` / PDP 归属**(PRD §9/§211):`` 组件 + `useDecision()` 注入点唯一归属 `@gocell/core`;PDP client 实现(`createPdpClient`)归属 `@gocell/access`,由 `apps/web` 装配层注入。Cells 页为只读,页面级 PDP 门由路由守卫(`requiredAction: 'read'`, `requiredResource: 'cell'`,fail-closed)执行。 -**已批准设计性例外**:可依赖 `@gocell/access`(消费其 PDP client 能力)——因 devboard 所有页面都依赖 PDP;其他业务包不享有此例外。 +## 测试 -**`` / PDP 归属**(PRD §9/§211):`` 组件 + `useDecision()` 注入点唯一归属 `@gocell/core`(UI 壳,全包可消费);PDP client 实现(`createPdpClient`)归属 `@gocell/access`,由 `apps/web` 装配层注入。devboard 经 `@gocell/core` 的 ``/`useDecision` 消费 PDP,与其他消费方一致。 +`pnpm -F @gocell/devboard test --run`(manifest 派生器:`pnpm -F @gocell/cell-manifest test --run`) diff --git a/packages/devboard/package.json b/packages/devboard/package.json index 9d169ef..321427c 100644 --- a/packages/devboard/package.json +++ b/packages/devboard/package.json @@ -4,12 +4,34 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./views/cells-list": "./src/views/CellsListView.vue", + "./views/cell-detail": "./src/views/CellDetailView.vue" }, "files": [ "src" ], "scripts": { - "typecheck": "vue-tsc --noEmit" + "typecheck": "vue-tsc --noEmit", + "test": "vitest run", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@gocell/core": "workspace:*", + "@gocell/request": "workspace:*", + "@gocell/contracts": "workspace:*", + "pinia": "catalog:", + "vue": "catalog:", + "vue-i18n": "catalog:", + "vue-router": "catalog:" + }, + "devDependencies": { + "vitest": "catalog:", + "@pinia/testing": "catalog:", + "@vitejs/plugin-vue": "catalog:", + "@vue/test-utils": "catalog:", + "axios-mock-adapter": "catalog:", + "jsdom": "catalog:", + "@vitest/coverage-v8": "catalog:" } } diff --git a/packages/devboard/src/api/cellAudit.spec.ts b/packages/devboard/src/api/cellAudit.spec.ts new file mode 100644 index 0000000..0d55c63 --- /dev/null +++ b/packages/devboard/src/api/cellAudit.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import MockAdapter from 'axios-mock-adapter' +import { http } from '@gocell/request' +import { fetchCellAudit, CELL_AUDIT_URL } from './cellAudit' + +const page = { + data: [ + { + id: 'evt-001', + eventId: 'eid-001', + eventType: 'slice.merge', + actorId: 'dario@gocell.dev', + subjectId: 'auditcore/persist@a7f3', + correlationId: 'corr-abc', + occurredAt: '2026-06-01T12:48:55Z', + timestamp: '2026-06-01T12:48:56Z', + payload: { pr: 2241, sha: 'a7f37c1' }, + }, + ], + nextCursor: 'cur-2', + hasMore: true, +} + +describe('cellAudit · fetchCellAudit', () => { + let mock: MockAdapter + + beforeEach(() => { + mock = new MockAdapter(http) + }) + + afterEach(() => { + mock.restore() + }) + + it('GETs CELL_AUDIT_URL and returns the response envelope', async () => { + mock.onGet(CELL_AUDIT_URL).reply(200, page) + const res = await fetchCellAudit() + expect(res).toEqual(page) + }) + + it('forwards limit param as a query parameter', async () => { + let seenParams: Record | undefined + mock.onGet(CELL_AUDIT_URL).reply((config) => { + seenParams = config.params as Record + return [200, page] + }) + await fetchCellAudit({ limit: 20 }) + expect(seenParams).toEqual({ limit: 20 }) + }) + + it('sends no query params when called without args', async () => { + let seenParams: Record | undefined + mock.onGet(CELL_AUDIT_URL).reply((config) => { + seenParams = config.params as Record + return [200, page] + }) + await fetchCellAudit() + expect(seenParams).toEqual({}) + }) + + it('rejects on network failure', async () => { + mock.onGet(CELL_AUDIT_URL).networkError() + await expect(fetchCellAudit()).rejects.toThrow() + }) +}) diff --git a/packages/devboard/src/api/cellAudit.ts b/packages/devboard/src/api/cellAudit.ts new file mode 100644 index 0000000..73cc58e --- /dev/null +++ b/packages/devboard/src/api/cellAudit.ts @@ -0,0 +1,40 @@ +/** + * cellAudit.ts — audit entries for the devboard Cell detail Audit tab. + * + * Boundary note: @gocell/devboard MUST NOT import @gocell/audit. + * This client re-calls the same HTTP endpoint directly via @gocell/request + * with contract types from @gocell/contracts (T505 boundary-clean approach). + * + * Backend gap: the /api/v1/audit/ endpoint has no per-cell filter param yet + * (no `cellId` query param). This client fetches recent entries globally + * and the Audit tab displays them as "recent activity" with a deep-link to + * the full /audit page. Once the backend adds per-cell filtering this + * function should accept a cellId param and pass it through. + */ +import { http } from '@gocell/request' +import type { HttpAuditListV1Response } from '@gocell/contracts' + +/** Collection endpoint for audit entries (same as @gocell/audit AUDIT_URL). */ +export const CELL_AUDIT_URL = '/api/v1/audit/' + +/** + * A single audit log entry derived from the codegen-produced response + * contract so the row type stays schema-bound. + */ +export type CellAuditEntry = HttpAuditListV1Response['data'][number] + +/** Request params for fetchCellAudit. */ +export interface FetchCellAuditParams { + limit?: number +} + +/** + * GET /api/v1/audit/ — fetch recent audit entries for display in the + * Cell detail Audit tab (global list, no per-cell filter yet). + */ +export async function fetchCellAudit( + params: FetchCellAuditParams = {}, +): Promise { + const res = await http.get(CELL_AUDIT_URL, { params }) + return res.data +} diff --git a/packages/devboard/src/api/cellConfig.spec.ts b/packages/devboard/src/api/cellConfig.spec.ts new file mode 100644 index 0000000..5d776f2 --- /dev/null +++ b/packages/devboard/src/api/cellConfig.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import MockAdapter from 'axios-mock-adapter' +import { http } from '@gocell/request' +import { fetchCellConfig, CELL_CONFIG_URL } from './cellConfig' + +const page = { + data: [ + { + id: 'cfg-001', + key: 'max_retries', + value: '3', + sensitive: false, + version: 2, + createdAt: '2026-05-10T08:00:00Z', + updatedAt: '2026-06-01T10:00:00Z', + }, + { + id: 'cfg-002', + key: 'db_password', + value: '******', + sensitive: true, + version: 1, + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + }, + ], + nextCursor: 'cur-3', + hasMore: false, +} + +describe('cellConfig · fetchCellConfig', () => { + let mock: MockAdapter + + beforeEach(() => { + mock = new MockAdapter(http) + }) + + afterEach(() => { + mock.restore() + }) + + it('GETs CELL_CONFIG_URL and returns the response envelope', async () => { + mock.onGet(CELL_CONFIG_URL).reply(200, page) + const res = await fetchCellConfig() + expect(res).toEqual(page) + }) + + it('forwards limit param as a query parameter', async () => { + let seenParams: Record | undefined + mock.onGet(CELL_CONFIG_URL).reply((config) => { + seenParams = config.params as Record + return [200, page] + }) + await fetchCellConfig({ limit: 50 }) + expect(seenParams).toEqual({ limit: 50 }) + }) + + it('sends no query params when called without args', async () => { + let seenParams: Record | undefined + mock.onGet(CELL_CONFIG_URL).reply((config) => { + seenParams = config.params as Record + return [200, page] + }) + await fetchCellConfig() + expect(seenParams).toEqual({}) + }) + + it('rejects on network failure', async () => { + mock.onGet(CELL_CONFIG_URL).networkError() + await expect(fetchCellConfig()).rejects.toThrow() + }) +}) diff --git a/packages/devboard/src/api/cellConfig.ts b/packages/devboard/src/api/cellConfig.ts new file mode 100644 index 0000000..d408bcb --- /dev/null +++ b/packages/devboard/src/api/cellConfig.ts @@ -0,0 +1,43 @@ +/** + * cellConfig.ts — configuration entries for the devboard Cell detail Configuration tab. + * + * Boundary note: @gocell/devboard MUST NOT import @gocell/config. + * This client re-calls the same HTTP endpoint directly via @gocell/request + * with contract types from @gocell/contracts (T505 boundary-clean approach). + * + * Backend gap: the /api/v1/config/ endpoint has no per-cell filter param yet + * (no `cellId` query param). This client fetches recent entries globally + * and the Configuration tab displays them as "recent config entries" with a + * deep-link to the full /config page. Once the backend adds per-cell filtering + * this function should accept a cellId param and pass it through. + * + * Sensitive entries: value="******" when sensitive=true. The tab renders this + * as-is and MUST NOT use the redacted placeholder as write input. + */ +import { http } from '@gocell/request' +import type { HttpConfigListV1Response } from '@gocell/contracts' + +/** Collection endpoint for config entries (same as @gocell/config CONFIG_URL). */ +export const CELL_CONFIG_URL = '/api/v1/config/' + +/** + * A single config entry derived from the codegen-produced response + * contract so the row type stays schema-bound. + */ +export type CellConfigEntry = HttpConfigListV1Response['data'][number] + +/** Request params for fetchCellConfig. */ +export interface FetchCellConfigParams { + limit?: number +} + +/** + * GET /api/v1/config/ — fetch recent config entries for display in the + * Cell detail Configuration tab (global list, no per-cell filter yet). + */ +export async function fetchCellConfig( + params: FetchCellConfigParams = {}, +): Promise { + const res = await http.get(CELL_CONFIG_URL, { params }) + return res.data +} diff --git a/packages/devboard/src/components/CellDurabilityBadge.spec.ts b/packages/devboard/src/components/CellDurabilityBadge.spec.ts new file mode 100644 index 0000000..f591a8f --- /dev/null +++ b/packages/devboard/src/components/CellDurabilityBadge.spec.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import CellDurabilityBadge from './CellDurabilityBadge.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + cells: { + badge: { + durable: 'Durable', + demo: 'Demo', + durableHint: 'Durable cell: state is persisted.', + demoHint: 'Demo cell: state is not persisted.', + }, + }, + }, + }, +}) + +describe('CellDurabilityBadge', () => { + it('renders "Durable" label for durable mode', () => { + const wrapper = mount(CellDurabilityBadge, { + props: { mode: 'durable' }, + global: { plugins: [i18n] }, + }) + expect(wrapper.text()).toContain('Durable') + }) + + it('renders "Demo" label for demo mode', () => { + const wrapper = mount(CellDurabilityBadge, { + props: { mode: 'demo' }, + global: { plugins: [i18n] }, + }) + expect(wrapper.text()).toContain('Demo') + }) + + it('dot element is aria-hidden for durable mode', () => { + const wrapper = mount(CellDurabilityBadge, { + props: { mode: 'durable' }, + global: { plugins: [i18n] }, + }) + const dot = wrapper.find('.badge__dot') + expect(dot.exists()).toBe(true) + expect(dot.attributes('aria-hidden')).toBe('true') + }) + + it('dot element is aria-hidden for demo mode', () => { + const wrapper = mount(CellDurabilityBadge, { + props: { mode: 'demo' }, + global: { plugins: [i18n] }, + }) + const dot = wrapper.find('.badge__dot') + expect(dot.exists()).toBe(true) + expect(dot.attributes('aria-hidden')).toBe('true') + }) + + it('applies durable variant class for durable mode', () => { + const wrapper = mount(CellDurabilityBadge, { + props: { mode: 'durable' }, + global: { plugins: [i18n] }, + }) + expect(wrapper.find('.badge--durable').exists()).toBe(true) + }) + + it('applies demo variant class for demo mode', () => { + const wrapper = mount(CellDurabilityBadge, { + props: { mode: 'demo' }, + global: { plugins: [i18n] }, + }) + expect(wrapper.find('.badge--demo').exists()).toBe(true) + }) +}) diff --git a/packages/devboard/src/components/CellDurabilityBadge.vue b/packages/devboard/src/components/CellDurabilityBadge.vue new file mode 100644 index 0000000..ca907ec --- /dev/null +++ b/packages/devboard/src/components/CellDurabilityBadge.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/packages/devboard/src/components/CellTabBar.spec.ts b/packages/devboard/src/components/CellTabBar.spec.ts new file mode 100644 index 0000000..641b5f0 --- /dev/null +++ b/packages/devboard/src/components/CellTabBar.spec.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import CellTabBar from './CellTabBar.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (k: string) => k }), +})) + +const TABS = [ + { id: 'overview', labelKey: 'cells.tabs.overview' }, + { id: 'interfaces', labelKey: 'cells.tabs.interfaces' }, + { id: 'wiring', labelKey: 'cells.tabs.wiring' }, +] as const + +describe('CellTabBar', () => { + it('renders one button per tab with role=tab', () => { + const wrapper = mount(CellTabBar, { + props: { tabs: TABS, activeId: 'overview', cellId: 'accesscore' }, + }) + const buttons = wrapper.findAll('[role="tab"]') + expect(buttons).toHaveLength(3) + }) + + it('active tab has aria-selected=true and tabindex=0', () => { + const wrapper = mount(CellTabBar, { + props: { tabs: TABS, activeId: 'interfaces', cellId: 'accesscore' }, + }) + const active = wrapper.find('[aria-selected="true"]') + expect(active.exists()).toBe(true) + expect(active.attributes('tabindex')).toBe('0') + }) + + it('inactive tabs have aria-selected=false and tabindex=-1', () => { + const wrapper = mount(CellTabBar, { + props: { tabs: TABS, activeId: 'overview', cellId: 'accesscore' }, + }) + const inactive = wrapper.findAll('[aria-selected="false"]') + expect(inactive).toHaveLength(2) + for (const btn of inactive) { + expect(btn.attributes('tabindex')).toBe('-1') + } + }) + + it('active tab has aria-controls matching panelId helper', () => { + const wrapper = mount(CellTabBar, { + props: { tabs: TABS, activeId: 'overview', cellId: 'mycel' }, + }) + const activeBtn = wrapper.find('[aria-selected="true"]') + expect(activeBtn.attributes('aria-controls')).toBe('cell-panel-mycel-overview') + }) + + it('inactive tabs have no aria-controls attribute', () => { + const wrapper = mount(CellTabBar, { + props: { tabs: TABS, activeId: 'overview', cellId: 'mycel' }, + }) + const inactiveBtns = wrapper.findAll('[aria-selected="false"]') + for (const btn of inactiveBtns) { + expect(btn.attributes('aria-controls')).toBeUndefined() + } + }) + + it('tab button id matches tabBtnId helper', () => { + const wrapper = mount(CellTabBar, { + props: { tabs: TABS, activeId: 'overview', cellId: 'mycel' }, + }) + const firstBtn = wrapper.findAll('[role="tab"]')[0] + expect(firstBtn?.attributes('id')).toBe('cell-tab-mycel-overview') + }) + + it('click emits select with the tab id', async () => { + const wrapper = mount(CellTabBar, { + props: { tabs: TABS, activeId: 'overview', cellId: 'accesscore' }, + }) + const secondBtn = wrapper.findAll('[role="tab"]')[1] + await secondBtn?.trigger('click') + expect(wrapper.emitted('select')).toBeTruthy() + expect(wrapper.emitted('select')?.[0]).toEqual(['interfaces']) + }) + + it('tablist has role=tablist', () => { + const wrapper = mount(CellTabBar, { + props: { tabs: TABS, activeId: 'overview', cellId: 'accesscore' }, + }) + expect(wrapper.find('[role="tablist"]').exists()).toBe(true) + }) + + it('tablist has aria-label from i18n key', () => { + const wrapper = mount(CellTabBar, { + props: { tabs: TABS, activeId: 'overview', cellId: 'accesscore' }, + }) + const tablist = wrapper.find('[role="tablist"]') + // i18n mock returns the key itself + expect(tablist.attributes('aria-label')).toBe('cells.detail.tablistLabel') + }) + + it('button text is the labelKey (via t())', () => { + const wrapper = mount(CellTabBar, { + props: { tabs: TABS, activeId: 'overview', cellId: 'accesscore' }, + }) + const firstBtn = wrapper.findAll('[role="tab"]')[0] + expect(firstBtn?.text()).toBe('cells.tabs.overview') + }) + + it('ArrowRight keydown moves focus to next tab button', async () => { + // Mount with jsdom — getElementById needs to exist in DOM + const wrapper = mount(CellTabBar, { + props: { tabs: TABS, activeId: 'overview', cellId: 'kc' }, + attachTo: document.body, + }) + const firstBtn = wrapper.find('#cell-tab-kc-overview') + const secondBtn = wrapper.find('#cell-tab-kc-interfaces') + const focusSpy = vi.spyOn(secondBtn.element as HTMLElement, 'focus') + await firstBtn.trigger('keydown', { key: 'ArrowRight' }) + expect(focusSpy).toHaveBeenCalled() + wrapper.unmount() + }) + + it('ArrowRight keydown emits select with the adjacent tab id (automatic activation)', async () => { + const wrapper = mount(CellTabBar, { + props: { tabs: TABS, activeId: 'overview', cellId: 'kc' }, + attachTo: document.body, + }) + const firstBtn = wrapper.find('#cell-tab-kc-overview') + await firstBtn.trigger('keydown', { key: 'ArrowRight' }) + expect(wrapper.emitted('select')).toBeTruthy() + expect(wrapper.emitted('select')?.[0]).toEqual(['interfaces']) + wrapper.unmount() + }) + + it('ArrowLeft keydown emits select with the adjacent tab id (automatic activation)', async () => { + const wrapper = mount(CellTabBar, { + props: { tabs: TABS, activeId: 'interfaces', cellId: 'kc' }, + attachTo: document.body, + }) + const secondBtn = wrapper.find('#cell-tab-kc-interfaces') + await secondBtn.trigger('keydown', { key: 'ArrowLeft' }) + expect(wrapper.emitted('select')).toBeTruthy() + expect(wrapper.emitted('select')?.[0]).toEqual(['overview']) + wrapper.unmount() + }) +}) diff --git a/packages/devboard/src/components/CellTabBar.vue b/packages/devboard/src/components/CellTabBar.vue new file mode 100644 index 0000000..39d8aed --- /dev/null +++ b/packages/devboard/src/components/CellTabBar.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/packages/devboard/src/components/UnavailablePanel.spec.ts b/packages/devboard/src/components/UnavailablePanel.spec.ts new file mode 100644 index 0000000..57f2f59 --- /dev/null +++ b/packages/devboard/src/components/UnavailablePanel.spec.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import UnavailablePanel from './UnavailablePanel.vue' + +describe('UnavailablePanel', () => { + it('renders the message prop', () => { + const wrapper = mount(UnavailablePanel, { + props: { message: 'Data is unavailable' }, + }) + expect(wrapper.text()).toContain('Data is unavailable') + }) + + it('has role="status"', () => { + const wrapper = mount(UnavailablePanel, { + props: { message: 'Some message' }, + }) + expect(wrapper.attributes('role')).toBe('status') + }) + + it('renders optional title prop when provided', () => { + const wrapper = mount(UnavailablePanel, { + props: { title: 'Not available', message: 'Try again later' }, + }) + expect(wrapper.text()).toContain('Not available') + expect(wrapper.text()).toContain('Try again later') + }) + + it('renders without title when title is omitted', () => { + const wrapper = mount(UnavailablePanel, { + props: { message: 'No title here' }, + }) + // Should not throw, title section should not render + expect(wrapper.find('.panel__title').exists()).toBe(false) + expect(wrapper.text()).toContain('No title here') + }) +}) diff --git a/packages/devboard/src/components/UnavailablePanel.vue b/packages/devboard/src/components/UnavailablePanel.vue new file mode 100644 index 0000000..4327b0a --- /dev/null +++ b/packages/devboard/src/components/UnavailablePanel.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/packages/devboard/src/components/tabs/AITab.spec.ts b/packages/devboard/src/components/tabs/AITab.spec.ts new file mode 100644 index 0000000..10b8cbc --- /dev/null +++ b/packages/devboard/src/components/tabs/AITab.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import AITab from './AITab.vue' +import type { CellEntry } from '../../manifest/types' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +const cell: CellEntry = { + id: 'auditcore', + name: 'AuditCore', + domain: 'Audit', + type: 'core', + consistencyLevel: 'L1', + lifecycle: 'stable', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'owner' }, + goStructName: 'AuditCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], +} + +describe('AITab', () => { + it('renders UnavailablePanel with the ai unavailable message', () => { + const wrapper = mount(AITab, { props: { cell } }) + expect(wrapper.text()).toContain('cells.unavailable.ai') + }) + + it('renders UnavailablePanel with the shared unavailable title', () => { + const wrapper = mount(AITab, { props: { cell } }) + expect(wrapper.text()).toContain('cells.unavailable.title') + }) + + it('renders a panel with role="status" (via UnavailablePanel)', () => { + const wrapper = mount(AITab, { props: { cell } }) + expect(wrapper.find('[role="status"]').exists()).toBe(true) + }) +}) diff --git a/packages/devboard/src/components/tabs/AITab.vue b/packages/devboard/src/components/tabs/AITab.vue new file mode 100644 index 0000000..fe92e60 --- /dev/null +++ b/packages/devboard/src/components/tabs/AITab.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/packages/devboard/src/components/tabs/AuditTab.spec.ts b/packages/devboard/src/components/tabs/AuditTab.spec.ts new file mode 100644 index 0000000..47bb204 --- /dev/null +++ b/packages/devboard/src/components/tabs/AuditTab.spec.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import { createTestPinia } from '@gocell/core/testing' +import AuditTab from './AuditTab.vue' +import type { CellEntry } from '../../manifest/types' + +vi.mock('../../api/cellAudit', () => ({ + fetchCellAudit: vi.fn(), +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('vue-router', () => ({ + RouterLink: defineComponent({ + props: { to: String }, + setup(props, { slots }) { + return () => h('a', { href: String(props.to) }, slots.default?.()) + }, + }), +})) + +import { fetchCellAudit } from '../../api/cellAudit' + +const mockFetch = vi.mocked(fetchCellAudit) + +const cell: CellEntry = { + id: 'auditcore', + name: 'AuditCore', + domain: 'Audit', + type: 'core', + consistencyLevel: 'L1', + lifecycle: 'stable', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'owner' }, + goStructName: 'AuditCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], +} + +const sampleEntries = [ + { + id: 'evt-001', + eventId: 'eid-001', + eventType: 'slice.merge', + actorId: 'dario@gocell.dev', + subjectId: 'auditcore/persist@a7f3', + correlationId: 'corr-abc', + occurredAt: '2026-06-01T12:48:55Z', + timestamp: '2026-06-01T12:48:56Z', + payload: { pr: 2241 }, + }, + { + id: 'evt-002', + eventId: 'eid-002', + eventType: 'config.update', + actorId: 'sam@gocell.dev', + timestamp: '2026-06-02T09:00:00Z', + }, +] + +/** Mount with a fresh testing Pinia (real actions run against the mocked fetch). */ +function mountTab() { + return mount(AuditTab, { + props: { cell }, + global: { plugins: [createTestPinia({ createSpy: vi.fn })] }, + }) +} + +describe('AuditTab', () => { + beforeEach(() => { + mockFetch.mockReset() + }) + + it('shows loading state before the fetch resolves', () => { + mockFetch.mockReturnValue(new Promise(() => {})) + const wrapper = mountTab() + expect(wrapper.find('[role="status"]').text()).toBe('cells.audit.loading') + expect(wrapper.find('table').exists()).toBe(false) + }) + + it('renders a table with entries after successful fetch', async () => { + mockFetch.mockResolvedValue({ + data: sampleEntries, + nextCursor: 'cur-2', + hasMore: false, + }) + const wrapper = mountTab() + await flushPromises() + + expect(wrapper.find('table').exists()).toBe(true) + const rows = wrapper.findAll('tbody tr') + expect(rows).toHaveLength(2) + expect(rows[0]?.text()).toContain('slice.merge') + expect(rows[0]?.text()).toContain('dario@gocell.dev') + }) + + it('renders column headers from i18n keys', async () => { + mockFetch.mockResolvedValue({ + data: sampleEntries, + nextCursor: '', + hasMore: false, + }) + const wrapper = mountTab() + await flushPromises() + + const headers = wrapper.findAll('th') + expect(headers[0]?.text()).toBe('cells.audit.action') + expect(headers[1]?.text()).toBe('cells.audit.actor') + expect(headers[2]?.text()).toBe('cells.audit.time') + }) + + it('shows empty state when data array is empty', async () => { + mockFetch.mockResolvedValue({ data: [], nextCursor: '', hasMore: false }) + const wrapper = mountTab() + await flushPromises() + + expect(wrapper.find('table').exists()).toBe(false) + expect(wrapper.text()).toContain('cells.audit.empty') + }) + + it('shows error state when fetch rejects', async () => { + mockFetch.mockRejectedValue(new Error('network error')) + const wrapper = mountTab() + await flushPromises() + + expect(wrapper.find('[role="alert"]').exists()).toBe(true) + expect(wrapper.text()).toContain('cells.audit.error') + expect(wrapper.find('table').exists()).toBe(false) + }) + + it('renders open-full link in data state', async () => { + mockFetch.mockResolvedValue({ + data: sampleEntries, + nextCursor: '', + hasMore: false, + }) + const wrapper = mountTab() + await flushPromises() + + const link = wrapper.find('a') + expect(link.exists()).toBe(true) + expect(link.text()).toBe('cells.audit.openFull') + }) + + it('calls fetchCellAudit with limit: 20', async () => { + mockFetch.mockResolvedValue({ data: [], nextCursor: '', hasMore: false }) + mountTab() + await flushPromises() + + expect(mockFetch).toHaveBeenCalledWith({ limit: 20 }) + }) + + it('reuses the cached entries on tab re-entry (no refetch)', async () => { + mockFetch.mockResolvedValue({ data: sampleEntries, nextCursor: '', hasMore: false }) + // Same store instance across both mounts → models leaving and re-entering the tab. + const pinia = createTestPinia({ createSpy: vi.fn }) + const first = mount(AuditTab, { props: { cell }, global: { plugins: [pinia] } }) + await flushPromises() + expect(mockFetch).toHaveBeenCalledTimes(1) + + first.unmount() + const second = mount(AuditTab, { props: { cell }, global: { plugins: [pinia] } }) + await flushPromises() + + // Cache hit: no second request, data shown immediately. + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(second.find('table').exists()).toBe(true) + }) +}) diff --git a/packages/devboard/src/components/tabs/AuditTab.vue b/packages/devboard/src/components/tabs/AuditTab.vue new file mode 100644 index 0000000..fa06ea1 --- /dev/null +++ b/packages/devboard/src/components/tabs/AuditTab.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/packages/devboard/src/components/tabs/ConfigurationTab.spec.ts b/packages/devboard/src/components/tabs/ConfigurationTab.spec.ts new file mode 100644 index 0000000..d91ec3d --- /dev/null +++ b/packages/devboard/src/components/tabs/ConfigurationTab.spec.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import { createTestPinia } from '@gocell/core/testing' +import ConfigurationTab from './ConfigurationTab.vue' +import type { CellEntry } from '../../manifest/types' + +vi.mock('../../api/cellConfig', () => ({ + fetchCellConfig: vi.fn(), +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('vue-router', () => ({ + RouterLink: defineComponent({ + props: { to: String }, + setup(props, { slots }) { + return () => h('a', { href: String(props.to) }, slots.default?.()) + }, + }), +})) + +import { fetchCellConfig } from '../../api/cellConfig' + +const mockFetch = vi.mocked(fetchCellConfig) + +const cell: CellEntry = { + id: 'configcore', + name: 'ConfigCore', + domain: 'Config', + type: 'core', + consistencyLevel: 'L1', + lifecycle: 'stable', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'owner' }, + goStructName: 'ConfigCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], +} + +const sampleEntries = [ + { + id: 'cfg-001', + key: 'max_retries', + value: '3', + sensitive: false, + version: 2, + createdAt: '2026-05-10T08:00:00Z', + updatedAt: '2026-06-01T10:00:00Z', + }, + { + id: 'cfg-002', + key: 'db_password', + value: '******', + sensitive: true, + version: 1, + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + }, +] + +/** Mount with a fresh testing Pinia (real actions run against the mocked fetch). */ +function mountTab() { + return mount(ConfigurationTab, { + props: { cell }, + global: { plugins: [createTestPinia({ createSpy: vi.fn })] }, + }) +} + +describe('ConfigurationTab', () => { + beforeEach(() => { + mockFetch.mockReset() + }) + + it('shows loading state before the fetch resolves', () => { + mockFetch.mockReturnValue(new Promise(() => {})) + const wrapper = mountTab() + expect(wrapper.find('[role="status"]').text()).toBe('cells.configuration.loading') + expect(wrapper.find('table').exists()).toBe(false) + }) + + it('renders a table with entries after successful fetch', async () => { + mockFetch.mockResolvedValue({ + data: sampleEntries, + nextCursor: 'cur-3', + hasMore: false, + }) + const wrapper = mountTab() + await flushPromises() + + expect(wrapper.find('table').exists()).toBe(true) + const rows = wrapper.findAll('tbody tr') + expect(rows).toHaveLength(2) + expect(rows[0]?.text()).toContain('max_retries') + expect(rows[0]?.text()).toContain('3') + expect(rows[1]?.text()).toContain('db_password') + expect(rows[1]?.text()).toContain('******') + }) + + it('renders column headers from i18n keys', async () => { + mockFetch.mockResolvedValue({ + data: sampleEntries, + nextCursor: '', + hasMore: false, + }) + const wrapper = mountTab() + await flushPromises() + + const headers = wrapper.findAll('th') + expect(headers[0]?.text()).toBe('cells.configuration.key') + expect(headers[1]?.text()).toBe('cells.configuration.value') + expect(headers[2]?.text()).toBe('cells.configuration.version') + }) + + it('shows empty state when data array is empty', async () => { + mockFetch.mockResolvedValue({ data: [], nextCursor: '', hasMore: false }) + const wrapper = mountTab() + await flushPromises() + + expect(wrapper.find('table').exists()).toBe(false) + expect(wrapper.text()).toContain('cells.configuration.empty') + }) + + it('shows error state when fetch rejects', async () => { + mockFetch.mockRejectedValue(new Error('network error')) + const wrapper = mountTab() + await flushPromises() + + expect(wrapper.find('[role="alert"]').exists()).toBe(true) + expect(wrapper.text()).toContain('cells.configuration.error') + expect(wrapper.find('table').exists()).toBe(false) + }) + + it('renders open-full link in data state', async () => { + mockFetch.mockResolvedValue({ + data: sampleEntries, + nextCursor: '', + hasMore: false, + }) + const wrapper = mountTab() + await flushPromises() + + const link = wrapper.find('a') + expect(link.exists()).toBe(true) + expect(link.text()).toBe('cells.configuration.openFull') + }) + + it('calls fetchCellConfig with limit: 50', async () => { + mockFetch.mockResolvedValue({ data: [], nextCursor: '', hasMore: false }) + mountTab() + await flushPromises() + + expect(mockFetch).toHaveBeenCalledWith({ limit: 50 }) + }) + + it('reuses the cached entries on tab re-entry (no refetch)', async () => { + mockFetch.mockResolvedValue({ data: sampleEntries, nextCursor: '', hasMore: false }) + // Same store instance across both mounts → models leaving and re-entering the tab. + const pinia = createTestPinia({ createSpy: vi.fn }) + const first = mount(ConfigurationTab, { props: { cell }, global: { plugins: [pinia] } }) + await flushPromises() + expect(mockFetch).toHaveBeenCalledTimes(1) + + first.unmount() + const second = mount(ConfigurationTab, { props: { cell }, global: { plugins: [pinia] } }) + await flushPromises() + + // Cache hit: no second request, data shown immediately. + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(second.find('table').exists()).toBe(true) + }) +}) diff --git a/packages/devboard/src/components/tabs/ConfigurationTab.vue b/packages/devboard/src/components/tabs/ConfigurationTab.vue new file mode 100644 index 0000000..03098f3 --- /dev/null +++ b/packages/devboard/src/components/tabs/ConfigurationTab.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/packages/devboard/src/components/tabs/ContractsTab.spec.ts b/packages/devboard/src/components/tabs/ContractsTab.spec.ts new file mode 100644 index 0000000..a1eaac2 --- /dev/null +++ b/packages/devboard/src/components/tabs/ContractsTab.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import type { CellEntry, ContractUsage } from '../../manifest/types' +import ContractsTab from './ContractsTab.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (k: string) => k }), +})) + +const makeCell = (overrides: Partial = {}): CellEntry => ({ + id: 'accesscore', + name: 'AccessCore', + domain: 'Access', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'AccessCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [ + { contract: 'access.authn.v1', role: 'serve' }, + { contract: 'access.authz.v1', role: 'publish' }, + ] satisfies ContractUsage[], + consumes: [ + { contract: 'audit.event.v1', role: 'call' }, + { contract: 'config.flag.v1', role: 'subscribe' }, + ] satisfies ContractUsage[], + dependsOnCells: [], + requiredByCells: [], + ...overrides, +}) + +describe('ContractsTab', () => { + it('renders contracts title key', () => { + const wrapper = mount(ContractsTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.contracts.title') + }) + + it('renders produces section label', () => { + const wrapper = mount(ContractsTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.contracts.produces') + }) + + it('renders consumes section label', () => { + const wrapper = mount(ContractsTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.contracts.consumes') + }) + + it('renders produces contracts in mono', () => { + const wrapper = mount(ContractsTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('access.authn.v1') + expect(wrapper.text()).toContain('access.authz.v1') + }) + + it('renders consumes contracts in mono', () => { + const wrapper = mount(ContractsTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('audit.event.v1') + expect(wrapper.text()).toContain('config.flag.v1') + }) + + it('renders role pills for produces', () => { + const wrapper = mount(ContractsTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.roles.serve') + expect(wrapper.text()).toContain('cells.roles.publish') + }) + + it('renders role pills for consumes', () => { + const wrapper = mount(ContractsTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.roles.call') + expect(wrapper.text()).toContain('cells.roles.subscribe') + }) + + it('shows none message when both produces and consumes are empty', () => { + const wrapper = mount(ContractsTab, { + props: { cell: makeCell({ produces: [], consumes: [] }) }, + }) + expect(wrapper.text()).toContain('cells.contracts.none') + }) + + it('shows none in produces section when produces empty but consumes not empty', () => { + const wrapper = mount(ContractsTab, { + props: { cell: makeCell({ produces: [] }) }, + }) + // Consumes should still render + expect(wrapper.text()).toContain('audit.event.v1') + }) + + it('produces table has scope=col headers', () => { + const wrapper = mount(ContractsTab, { props: { cell: makeCell() } }) + const headers = wrapper.findAll('th[scope="col"]') + expect(headers.length).toBeGreaterThanOrEqual(2) + }) + + it('contract column header key rendered', () => { + const wrapper = mount(ContractsTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.contracts.contract') + }) + + it('role column header key rendered', () => { + const wrapper = mount(ContractsTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.contracts.role') + }) +}) diff --git a/packages/devboard/src/components/tabs/ContractsTab.vue b/packages/devboard/src/components/tabs/ContractsTab.vue new file mode 100644 index 0000000..50bb4d1 --- /dev/null +++ b/packages/devboard/src/components/tabs/ContractsTab.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/packages/devboard/src/components/tabs/DependenciesTab.spec.ts b/packages/devboard/src/components/tabs/DependenciesTab.spec.ts new file mode 100644 index 0000000..edf451b --- /dev/null +++ b/packages/devboard/src/components/tabs/DependenciesTab.spec.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import type { CellEntry } from '../../manifest/types' +import DependenciesTab from './DependenciesTab.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (k: string) => k }), +})) + +vi.mock('vue-router', () => ({ + RouterLink: { + name: 'RouterLink', + props: { to: [String, Object] }, + template: '', + }, + useRouter: () => ({}), + useRoute: () => ({}), +})) + +// Stub RouterLink to avoid needing a full router setup +const RouterLinkStub = { + name: 'RouterLink', + props: { to: [String, Object] }, + template: '', +} + +const makeCell = (overrides: Partial = {}): CellEntry => ({ + id: 'accesscore', + name: 'AccessCore', + domain: 'Access', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'AccessCore', + schemaPrimary: null, + requires: ['postgres', 'redis'], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: ['auditcore', 'configcore'], + requiredByCells: ['observabilitycore'], + ...overrides, +}) + +const mountOpts = { + global: { + components: { RouterLink: RouterLinkStub }, + }, +} + +describe('DependenciesTab', () => { + it('renders dependencies title key', () => { + const wrapper = mount(DependenciesTab, { props: { cell: makeCell() }, ...mountOpts }) + expect(wrapper.text()).toContain('cells.dependencies.title') + }) + + it('renders hint key', () => { + const wrapper = mount(DependenciesTab, { props: { cell: makeCell() }, ...mountOpts }) + expect(wrapper.text()).toContain('cells.dependencies.hint') + }) + + it('renders dependsOn section label', () => { + const wrapper = mount(DependenciesTab, { props: { cell: makeCell() }, ...mountOpts }) + expect(wrapper.text()).toContain('cells.dependencies.dependsOn') + }) + + it('renders requiredBy section label', () => { + const wrapper = mount(DependenciesTab, { props: { cell: makeCell() }, ...mountOpts }) + expect(wrapper.text()).toContain('cells.dependencies.requiredBy') + }) + + it('renders infra section label', () => { + const wrapper = mount(DependenciesTab, { props: { cell: makeCell() }, ...mountOpts }) + expect(wrapper.text()).toContain('cells.dependencies.infra') + }) + + it('renders dependsOnCells as RouterLinks', () => { + const wrapper = mount(DependenciesTab, { props: { cell: makeCell() }, ...mountOpts }) + expect(wrapper.text()).toContain('auditcore') + expect(wrapper.text()).toContain('configcore') + }) + + it('renders requiredByCells as RouterLinks', () => { + const wrapper = mount(DependenciesTab, { props: { cell: makeCell() }, ...mountOpts }) + expect(wrapper.text()).toContain('observabilitycore') + }) + + it('renders infra dependencies', () => { + const wrapper = mount(DependenciesTab, { props: { cell: makeCell() }, ...mountOpts }) + expect(wrapper.text()).toContain('postgres') + expect(wrapper.text()).toContain('redis') + }) + + it('shows none message when dependsOnCells and requiredByCells are empty', () => { + const wrapper = mount(DependenciesTab, { + props: { cell: makeCell({ dependsOnCells: [], requiredByCells: [] }) }, + ...mountOpts, + }) + expect(wrapper.text()).toContain('cells.dependencies.none') + }) + + it('does not show none when dependsOnCells non-empty', () => { + const wrapper = mount(DependenciesTab, { props: { cell: makeCell() }, ...mountOpts }) + expect(wrapper.text()).not.toContain('cells.dependencies.none') + }) + + it('RouterLink to attribute contains cell id', () => { + const wrapper = mount(DependenciesTab, { props: { cell: makeCell() }, ...mountOpts }) + const links = wrapper.findAll('a') + const hrefs = links.map((l) => l.attributes('href')) + expect(hrefs.some((h) => h?.includes('auditcore'))).toBe(true) + }) +}) diff --git a/packages/devboard/src/components/tabs/DependenciesTab.vue b/packages/devboard/src/components/tabs/DependenciesTab.vue new file mode 100644 index 0000000..d3c631c --- /dev/null +++ b/packages/devboard/src/components/tabs/DependenciesTab.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/packages/devboard/src/components/tabs/GroupsTab.spec.ts b/packages/devboard/src/components/tabs/GroupsTab.spec.ts new file mode 100644 index 0000000..3429655 --- /dev/null +++ b/packages/devboard/src/components/tabs/GroupsTab.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import GroupsTab from './GroupsTab.vue' +import type { CellEntry } from '../../manifest/types' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +const cell: CellEntry = { + id: 'auditcore', + name: 'AuditCore', + domain: 'Audit', + type: 'core', + consistencyLevel: 'L1', + lifecycle: 'stable', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'owner' }, + goStructName: 'AuditCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], +} + +describe('GroupsTab', () => { + it('renders UnavailablePanel with the groups unavailable message', () => { + const wrapper = mount(GroupsTab, { props: { cell } }) + expect(wrapper.text()).toContain('cells.unavailable.groups') + }) + + it('renders UnavailablePanel with the shared unavailable title', () => { + const wrapper = mount(GroupsTab, { props: { cell } }) + expect(wrapper.text()).toContain('cells.unavailable.title') + }) + + it('renders a panel with role="status" (via UnavailablePanel)', () => { + const wrapper = mount(GroupsTab, { props: { cell } }) + expect(wrapper.find('[role="status"]').exists()).toBe(true) + }) +}) diff --git a/packages/devboard/src/components/tabs/GroupsTab.vue b/packages/devboard/src/components/tabs/GroupsTab.vue new file mode 100644 index 0000000..0ba662d --- /dev/null +++ b/packages/devboard/src/components/tabs/GroupsTab.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/devboard/src/components/tabs/InterfacesTab.spec.ts b/packages/devboard/src/components/tabs/InterfacesTab.spec.ts new file mode 100644 index 0000000..40a7677 --- /dev/null +++ b/packages/devboard/src/components/tabs/InterfacesTab.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import type { CellEntry, ContractUsage } from '../../manifest/types' +import InterfacesTab from './InterfacesTab.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (k: string) => k }), +})) + +vi.mock('../UnavailablePanel.vue', () => ({ + default: { template: '
' }, +})) + +const makeCell = (overrides: Partial = {}): CellEntry => ({ + id: 'accesscore', + name: 'AccessCore', + domain: 'Access', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'AccessCore', + schemaPrimary: null, + requires: ['postgres'], + l0Dependencies: [], + smokeTests: ['smoke-auth', 'smoke-policy'], + slices: [ + { + id: 'access.auth', + belongsToCell: 'accesscore', + consistencyLevel: 'L2', + lifecycle: 'asset', + contractUsages: [], + unitTests: [], + contractTests: [], + waivers: [], + }, + ], + produces: [ + { contract: 'access.authn.v1', role: 'serve' }, + { contract: 'access.authz.v1', role: 'serve' }, + ] satisfies ContractUsage[], + consumes: [{ contract: 'audit.event.v1', role: 'call' }] satisfies ContractUsage[], + dependsOnCells: ['auditcore'], + requiredByCells: [], + ...overrides, +}) + +describe('InterfacesTab', () => { + it('renders ISP title key', () => { + const wrapper = mount(InterfacesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.interfaces.ispTitle') + }) + + it('renders ISP hint key', () => { + const wrapper = mount(InterfacesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.interfaces.ispHint') + }) + + it('renders Identity card with cell id', () => { + const wrapper = mount(InterfacesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('accesscore') + }) + + it('renders Identity card with team', () => { + const wrapper = mount(InterfacesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('platform') + }) + + it('renders Lifecycle card with lifecycle value', () => { + const wrapper = mount(InterfacesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('asset') + }) + + it('renders Status card with UnavailablePanel (degraded)', () => { + const wrapper = mount(InterfacesTab, { props: { cell: makeCell() } }) + expect(wrapper.find('[data-testid="unavailable-panel"]').exists()).toBe(true) + }) + + it('renders Inventory card with slice count', () => { + const wrapper = mount(InterfacesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('1') + }) + + it('renders produces contracts list', () => { + const wrapper = mount(InterfacesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('access.authn.v1') + expect(wrapper.text()).toContain('access.authz.v1') + }) + + it('renders consumes contracts list', () => { + const wrapper = mount(InterfacesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('audit.event.v1') + }) + + it('shows noProduces message when produces is empty', () => { + const wrapper = mount(InterfacesTab, { props: { cell: makeCell({ produces: [] }) } }) + expect(wrapper.text()).toContain('cells.interfaces.noProduces') + }) + + it('shows noConsumes message when consumes is empty', () => { + const wrapper = mount(InterfacesTab, { props: { cell: makeCell({ consumes: [] }) } }) + expect(wrapper.text()).toContain('cells.interfaces.noConsumes') + }) + + it('renders role pills for produces contracts', () => { + const wrapper = mount(InterfacesTab, { props: { cell: makeCell() } }) + // role pill text = t('cells.roles.serve') = key itself via mock + expect(wrapper.text()).toContain('cells.roles.serve') + }) + + it('renders role pills for consumes contracts', () => { + const wrapper = mount(InterfacesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.roles.call') + }) + + it('renders smoke test count in Inventory card', () => { + const wrapper = mount(InterfacesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('2') + }) +}) diff --git a/packages/devboard/src/components/tabs/InterfacesTab.vue b/packages/devboard/src/components/tabs/InterfacesTab.vue new file mode 100644 index 0000000..600ba5a --- /dev/null +++ b/packages/devboard/src/components/tabs/InterfacesTab.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/packages/devboard/src/components/tabs/InventoryTab.spec.ts b/packages/devboard/src/components/tabs/InventoryTab.spec.ts new file mode 100644 index 0000000..2f6bede --- /dev/null +++ b/packages/devboard/src/components/tabs/InventoryTab.spec.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import type { CellEntry, ContractUsage } from '../../manifest/types' +import InventoryTab from './InventoryTab.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (k: string) => k }), +})) + +const makeCell = (overrides: Partial = {}): CellEntry => ({ + id: 'accesscore', + name: 'AccessCore', + domain: 'Access', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'AccessCore', + schemaPrimary: null, + requires: ['postgres', 'redis'], + l0Dependencies: [], + smokeTests: ['smoke-a', 'smoke-b', 'smoke-c'], + slices: [ + { + id: 'access.auth', + belongsToCell: 'accesscore', + consistencyLevel: 'L2', + lifecycle: 'asset', + contractUsages: [], + unitTests: [], + contractTests: [], + waivers: [], + }, + { + id: 'access.policy', + belongsToCell: 'accesscore', + consistencyLevel: 'L1', + lifecycle: 'asset', + contractUsages: [], + unitTests: [], + contractTests: [], + waivers: [], + }, + ], + produces: [{ contract: 'access.authn.v1', role: 'serve' }] satisfies ContractUsage[], + consumes: [ + { contract: 'audit.event.v1', role: 'call' }, + { contract: 'config.flag.v1', role: 'call' }, + ] satisfies ContractUsage[], + dependsOnCells: [], + requiredByCells: [], + ...overrides, +}) + +describe('InventoryTab', () => { + it('renders inventory title key', () => { + const wrapper = mount(InventoryTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.inventory.title') + }) + + it('renders slice count', () => { + const wrapper = mount(InventoryTab, { props: { cell: makeCell() } }) + // 2 slices + expect(wrapper.text()).toContain('2') + }) + + it('renders produces count', () => { + const wrapper = mount(InventoryTab, { props: { cell: makeCell() } }) + // 1 produced contract + expect(wrapper.text()).toContain('1') + }) + + it('renders consumes count', () => { + const wrapper = mount(InventoryTab, { props: { cell: makeCell() } }) + // 2 consumed contracts + expect(wrapper.text()).toContain('2') + }) + + it('renders requires count', () => { + const wrapper = mount(InventoryTab, { props: { cell: makeCell() } }) + // 2 requires + expect(wrapper.text()).toContain('2') + }) + + it('renders smoke test count', () => { + const wrapper = mount(InventoryTab, { props: { cell: makeCell() } }) + // 3 smoke tests + expect(wrapper.text()).toContain('3') + }) + + it('renders total contracts count (produces + consumes)', () => { + const wrapper = mount(InventoryTab, { props: { cell: makeCell() } }) + // 1 + 2 = 3 + expect(wrapper.text()).toContain('3') + }) + + it('table has scope=col on headers', () => { + const wrapper = mount(InventoryTab, { props: { cell: makeCell() } }) + const headers = wrapper.findAll('th[scope="col"]') + expect(headers.length).toBeGreaterThanOrEqual(2) + }) + + it('renders inventory slices label', () => { + const wrapper = mount(InventoryTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.inventory.slices') + }) + + it('renders inventory contracts label', () => { + const wrapper = mount(InventoryTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.inventory.contracts') + }) +}) diff --git a/packages/devboard/src/components/tabs/InventoryTab.vue b/packages/devboard/src/components/tabs/InventoryTab.vue new file mode 100644 index 0000000..d88f0aa --- /dev/null +++ b/packages/devboard/src/components/tabs/InventoryTab.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/packages/devboard/src/components/tabs/OverviewTab.spec.ts b/packages/devboard/src/components/tabs/OverviewTab.spec.ts new file mode 100644 index 0000000..cdfbf5e --- /dev/null +++ b/packages/devboard/src/components/tabs/OverviewTab.spec.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import type { CellEntry } from '../../manifest/types' +import OverviewTab from './OverviewTab.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (k: string) => k }), +})) + +// Stub child components to avoid their own i18n/router deps +vi.mock('../CellDurabilityBadge.vue', () => ({ + default: { template: '' }, +})) +vi.mock('../UnavailablePanel.vue', () => ({ + default: { template: '
' }, +})) + +const makeCell = (overrides: Partial = {}): CellEntry => ({ + id: 'accesscore', + name: 'AccessCore', + domain: 'Access', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'AccessCore', + schemaPrimary: 'access.v1', + requires: ['postgres', 'redis'], + l0Dependencies: [], + smokeTests: ['smoke-auth'], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], + ...overrides, +}) + +describe('OverviewTab', () => { + it('renders cell id', () => { + const wrapper = mount(OverviewTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('accesscore') + }) + + it('renders cell name', () => { + const wrapper = mount(OverviewTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('AccessCore') + }) + + it('renders domain', () => { + const wrapper = mount(OverviewTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('Access') + }) + + it('renders owner team', () => { + const wrapper = mount(OverviewTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('platform') + }) + + it('renders consistencyLevel', () => { + const wrapper = mount(OverviewTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('L2') + }) + + it('renders lifecycle', () => { + const wrapper = mount(OverviewTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('asset') + }) + + it('renders goStructName', () => { + const wrapper = mount(OverviewTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('AccessCore') + }) + + it('renders schemaPrimary when present', () => { + const wrapper = mount(OverviewTab, { + props: { cell: makeCell({ schemaPrimary: 'access.v1' }) }, + }) + expect(wrapper.text()).toContain('access.v1') + }) + + it('shows unavailable label when schemaPrimary is null', () => { + const wrapper = mount(OverviewTab, { props: { cell: makeCell({ schemaPrimary: null }) } }) + // i18n key returned as-is by mock + expect(wrapper.text()).toContain('cells.unavailable.label') + }) + + it('renders CellDurabilityBadge', () => { + const wrapper = mount(OverviewTab, { props: { cell: makeCell() } }) + expect(wrapper.find('[data-testid="durability-badge"]').exists()).toBe(true) + }) + + it('renders UnavailablePanel for Health section (degraded)', () => { + const wrapper = mount(OverviewTab, { props: { cell: makeCell() } }) + // Health and Runtime are degraded — at least one UnavailablePanel expected + const panels = wrapper.findAll('[data-testid="unavailable-panel"]') + expect(panels.length).toBeGreaterThanOrEqual(1) + }) + + it('does not render fabricated runtime numbers', () => { + const wrapper = mount(OverviewTab, { props: { cell: makeCell() } }) + // Should NOT contain QPS, p95, replicas etc. as those are backend-only + expect(wrapper.text()).not.toContain('QPS') + expect(wrapper.text()).not.toContain('p95') + }) +}) diff --git a/packages/devboard/src/components/tabs/OverviewTab.vue b/packages/devboard/src/components/tabs/OverviewTab.vue new file mode 100644 index 0000000..fdc7973 --- /dev/null +++ b/packages/devboard/src/components/tabs/OverviewTab.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/packages/devboard/src/components/tabs/SlicesTab.spec.ts b/packages/devboard/src/components/tabs/SlicesTab.spec.ts new file mode 100644 index 0000000..68bd1f4 --- /dev/null +++ b/packages/devboard/src/components/tabs/SlicesTab.spec.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import type { CellEntry, CellSlice, ContractUsage, CellWaiver } from '../../manifest/types' +import SlicesTab from './SlicesTab.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (k: string, p?: Record) => (p ? `${k}:${JSON.stringify(p)}` : k), + }), +})) + +const waiver: CellWaiver = { + contract: 'access.authn.v1', + owner: 'platform', + reason: 'legacy migration', + expiresAt: '2026-12-31', +} + +const slice1: CellSlice = { + id: 'access.auth', + belongsToCell: 'accesscore', + consistencyLevel: 'L2', + lifecycle: 'asset', + contractUsages: [{ contract: 'access.authn.v1', role: 'serve' }] satisfies ContractUsage[], + unitTests: [], + contractTests: [], + waivers: [waiver], +} + +const slice2: CellSlice = { + id: 'access.policy', + belongsToCell: 'accesscore', + consistencyLevel: 'L1', + lifecycle: 'asset', + contractUsages: [], + unitTests: [], + contractTests: [], + waivers: [], +} + +const makeCell = (overrides: Partial = {}): CellEntry => ({ + id: 'accesscore', + name: 'AccessCore', + domain: 'Access', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'AccessCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [slice1, slice2], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], + ...overrides, +}) + +describe('SlicesTab', () => { + it('renders slices title key', () => { + const wrapper = mount(SlicesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.slices.title') + }) + + it('renders slice id in mono', () => { + const wrapper = mount(SlicesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('access.auth') + expect(wrapper.text()).toContain('access.policy') + }) + + it('renders consistency level for each slice', () => { + const wrapper = mount(SlicesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('L2') + expect(wrapper.text()).toContain('L1') + }) + + it('renders contract usage count for each slice', () => { + const wrapper = mount(SlicesTab, { props: { cell: makeCell() } }) + // slice1 has 1 contract usage, slice2 has 0 + expect(wrapper.text()).toContain('1') + }) + + it('renders waiver count and expires for slices with waivers', () => { + const wrapper = mount(SlicesTab, { props: { cell: makeCell() } }) + // waiver expires 2026-12-31 + expect(wrapper.text()).toContain('2026-12-31') + }) + + it('renders waiverExpires i18n key for slice with waivers', () => { + const wrapper = mount(SlicesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.slices.waiverExpires') + }) + + it('shows none message when slices is empty', () => { + const wrapper = mount(SlicesTab, { props: { cell: makeCell({ slices: [] }) } }) + expect(wrapper.text()).toContain('cells.slices.none') + }) + + it('does not show none message when slices is non-empty', () => { + const wrapper = mount(SlicesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).not.toContain('cells.slices.none') + }) + + it('table has scope=col on headers', () => { + const wrapper = mount(SlicesTab, { props: { cell: makeCell() } }) + const headers = wrapper.findAll('th[scope="col"]') + expect(headers.length).toBeGreaterThanOrEqual(3) + }) + + it('renders slices column header key', () => { + const wrapper = mount(SlicesTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.slices.slice') + }) + + it('slice with no waivers renders 0 or empty waivers cell', () => { + const wrapper = mount(SlicesTab, { props: { cell: makeCell() } }) + // slice2 has 0 waivers — should render 0 or a dash + const rows = wrapper.findAll('tbody tr') + expect(rows.length).toBe(2) + }) +}) diff --git a/packages/devboard/src/components/tabs/SlicesTab.vue b/packages/devboard/src/components/tabs/SlicesTab.vue new file mode 100644 index 0000000..9c9b46c --- /dev/null +++ b/packages/devboard/src/components/tabs/SlicesTab.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/packages/devboard/src/components/tabs/TasksTab.spec.ts b/packages/devboard/src/components/tabs/TasksTab.spec.ts new file mode 100644 index 0000000..41ac105 --- /dev/null +++ b/packages/devboard/src/components/tabs/TasksTab.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import TasksTab from './TasksTab.vue' +import type { CellEntry } from '../../manifest/types' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +const cell: CellEntry = { + id: 'auditcore', + name: 'AuditCore', + domain: 'Audit', + type: 'core', + consistencyLevel: 'L1', + lifecycle: 'stable', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'owner' }, + goStructName: 'AuditCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], +} + +describe('TasksTab', () => { + it('renders UnavailablePanel with the tasks unavailable message', () => { + const wrapper = mount(TasksTab, { props: { cell } }) + expect(wrapper.text()).toContain('cells.unavailable.tasks') + }) + + it('renders UnavailablePanel with the shared unavailable title', () => { + const wrapper = mount(TasksTab, { props: { cell } }) + expect(wrapper.text()).toContain('cells.unavailable.title') + }) + + it('renders a panel with role="status" (via UnavailablePanel)', () => { + const wrapper = mount(TasksTab, { props: { cell } }) + expect(wrapper.find('[role="status"]').exists()).toBe(true) + }) +}) diff --git a/packages/devboard/src/components/tabs/TasksTab.vue b/packages/devboard/src/components/tabs/TasksTab.vue new file mode 100644 index 0000000..de39739 --- /dev/null +++ b/packages/devboard/src/components/tabs/TasksTab.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/devboard/src/components/tabs/WiringTab.spec.ts b/packages/devboard/src/components/tabs/WiringTab.spec.ts new file mode 100644 index 0000000..de51042 --- /dev/null +++ b/packages/devboard/src/components/tabs/WiringTab.spec.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import type { CellEntry } from '../../manifest/types' +import WiringTab from './WiringTab.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (k: string) => k }), +})) + +const makeCell = (overrides: Partial = {}): CellEntry => ({ + id: 'accesscore', + name: 'AccessCore', + domain: 'Access', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'AccessCore', + schemaPrimary: null, + requires: ['postgres', 'redis'], + l0Dependencies: ['kernel'], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], + ...overrides, +}) + +describe('WiringTab', () => { + it('renders wiring title key', () => { + const wrapper = mount(WiringTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.wiring.title') + }) + + it('renders wiring hint key', () => { + const wrapper = mount(WiringTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.wiring.hint') + }) + + it('renders requires section with infra deps', () => { + const wrapper = mount(WiringTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('postgres') + expect(wrapper.text()).toContain('redis') + }) + + it('renders l0Dependencies section', () => { + const wrapper = mount(WiringTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('kernel') + }) + + it('renders infraToCell key in SVG diagram', () => { + const wrapper = mount(WiringTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.wiring.infraToCell') + }) + + it('renders an inline SVG', () => { + const wrapper = mount(WiringTab, { props: { cell: makeCell() } }) + expect(wrapper.find('svg').exists()).toBe(true) + }) + + it('shows none message when both requires and l0Dependencies are empty', () => { + const wrapper = mount(WiringTab, { + props: { cell: makeCell({ requires: [], l0Dependencies: [] }) }, + }) + expect(wrapper.text()).toContain('cells.wiring.none') + }) + + it('does not show none message when requires is non-empty', () => { + const wrapper = mount(WiringTab, { + props: { cell: makeCell({ requires: ['postgres'], l0Dependencies: [] }) }, + }) + expect(wrapper.text()).not.toContain('cells.wiring.none') + }) + + it('renders requires section label', () => { + const wrapper = mount(WiringTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.wiring.requires') + }) + + it('renders l0deps section label', () => { + const wrapper = mount(WiringTab, { props: { cell: makeCell() } }) + expect(wrapper.text()).toContain('cells.wiring.l0deps') + }) +}) diff --git a/packages/devboard/src/components/tabs/WiringTab.vue b/packages/devboard/src/components/tabs/WiringTab.vue new file mode 100644 index 0000000..afc3ce5 --- /dev/null +++ b/packages/devboard/src/components/tabs/WiringTab.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/packages/devboard/src/components/tabs/tabRegistry.ts b/packages/devboard/src/components/tabs/tabRegistry.ts new file mode 100644 index 0000000..009eb38 --- /dev/null +++ b/packages/devboard/src/components/tabs/tabRegistry.ts @@ -0,0 +1,70 @@ +import { markRaw, type Component } from 'vue' +import OverviewTab from './OverviewTab.vue' +import InterfacesTab from './InterfacesTab.vue' +import WiringTab from './WiringTab.vue' +import InventoryTab from './InventoryTab.vue' +import SlicesTab from './SlicesTab.vue' +import ContractsTab from './ContractsTab.vue' +import DependenciesTab from './DependenciesTab.vue' +import TasksTab from './TasksTab.vue' +import ConfigurationTab from './ConfigurationTab.vue' +import AuditTab from './AuditTab.vue' +import GroupsTab from './GroupsTab.vue' +import AITab from './AITab.vue' + +/** + * tabRegistry — single source of truth for the cell-detail tab set. + * + * Order matches the design reference (dev-cell.jsx:281). Each tab takes the + * uniform `cell: CellEntry` prop. `CellDetailView` renders only the active + * tab's component (single live tabpanel — avoids 12 simultaneous mounts and + * the Audit/Config fetches firing for hidden panels). `CellTabBar` consumes + * only the `{ id, labelKey }` projection (`CELL_TAB_DEFS`). + * + * Components are `markRaw`-wrapped so they are never made reactive when held + * in a computed. + */ +export interface CellTab { + readonly id: string + readonly labelKey: string + readonly component: Component +} + +export const CELL_TABS: readonly CellTab[] = [ + { id: 'overview', labelKey: 'cells.tabs.overview', component: markRaw(OverviewTab) }, + { id: 'interfaces', labelKey: 'cells.tabs.interfaces', component: markRaw(InterfacesTab) }, + { id: 'wiring', labelKey: 'cells.tabs.wiring', component: markRaw(WiringTab) }, + { id: 'inventory', labelKey: 'cells.tabs.inventory', component: markRaw(InventoryTab) }, + { id: 'slices', labelKey: 'cells.tabs.slices', component: markRaw(SlicesTab) }, + { id: 'contracts', labelKey: 'cells.tabs.contracts', component: markRaw(ContractsTab) }, + { id: 'dependencies', labelKey: 'cells.tabs.dependencies', component: markRaw(DependenciesTab) }, + { id: 'tasks', labelKey: 'cells.tabs.tasks', component: markRaw(TasksTab) }, + { + id: 'configuration', + labelKey: 'cells.tabs.configuration', + component: markRaw(ConfigurationTab), + }, + { id: 'audit', labelKey: 'cells.tabs.audit', component: markRaw(AuditTab) }, + { id: 'groups', labelKey: 'cells.tabs.groups', component: markRaw(GroupsTab) }, + { id: 'ai', labelKey: 'cells.tabs.ai', component: markRaw(AITab) }, +] + +/** `{ id, labelKey }` projection for the (component-agnostic) tab bar. */ +export const CELL_TAB_DEFS: readonly { id: string; labelKey: string }[] = CELL_TABS.map( + ({ id, labelKey }) => ({ id, labelKey }), +) + +/** Default tab id when the URL carries no (or an unknown) `?tab=`. */ +export const DEFAULT_CELL_TAB = 'overview' + +/** Whether `id` is a known tab. */ +export function isCellTab(id: string): boolean { + return CELL_TABS.some((tab) => tab.id === id) +} + +/** Resolve a tab id to its component, falling back to the default tab. */ +export function cellTabComponent(id: string): Component { + const tab = CELL_TABS.find((t) => t.id === id) ?? CELL_TABS.find((t) => t.id === DEFAULT_CELL_TAB) + if (!tab) throw new Error(`cellTabComponent: no tab "${id}" and no default tab registered`) + return tab.component +} diff --git a/packages/devboard/src/composables/useCellsFilter.spec.ts b/packages/devboard/src/composables/useCellsFilter.spec.ts new file mode 100644 index 0000000..f762561 --- /dev/null +++ b/packages/devboard/src/composables/useCellsFilter.spec.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest' +import { ref, computed } from 'vue' +import { useCellsFilter } from './useCellsFilter' +import type { CellEntry } from '../manifest/types' + +const makeCell = (overrides: Partial): CellEntry => ({ + id: 'testcore', + name: 'TestCore', + domain: 'Test', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'TestCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], + ...overrides, +}) + +const CELLS: readonly CellEntry[] = [ + makeCell({ id: 'accesscore', name: 'AccessCore', domain: 'Access' }), + makeCell({ id: 'auditcore', name: 'AuditCore', domain: 'Audit' }), + makeCell({ id: 'configcore', name: 'ConfigCore', domain: 'Config' }), +] + +describe('useCellsFilter', () => { + it('empty query + domain=all → returns all cells', () => { + const cells = ref(CELLS) + const { filtered } = useCellsFilter(cells) + expect(filtered.value).toHaveLength(3) + }) + + it('domain filter excludes cells from other domains', () => { + const cells = ref(CELLS) + const { domain, filtered } = useCellsFilter(cells) + domain.value = 'Access' + expect(filtered.value).toHaveLength(1) + expect(filtered.value[0]?.id).toBe('accesscore') + }) + + it('query matches id substring (case-insensitive)', () => { + const cells = ref(CELLS) + const { query, filtered } = useCellsFilter(cells) + query.value = 'AUDIT' + expect(filtered.value).toHaveLength(1) + expect(filtered.value[0]?.id).toBe('auditcore') + }) + + it('query matches name substring (case-insensitive)', () => { + const cells = ref(CELLS) + const { query, filtered } = useCellsFilter(cells) + query.value = 'configc' + expect(filtered.value).toHaveLength(1) + expect(filtered.value[0]?.id).toBe('configcore') + }) + + it('combined domain + query = intersection', () => { + const cells = ref(CELLS) + const { query, domain, filtered } = useCellsFilter(cells) + domain.value = 'Access' + query.value = 'core' + // 'core' matches all ids, but domain=Access limits to 1 + expect(filtered.value).toHaveLength(1) + expect(filtered.value[0]?.domain).toBe('Access') + }) + + it('combined domain + query with no match → empty', () => { + const cells = ref(CELLS) + const { query, domain, filtered } = useCellsFilter(cells) + domain.value = 'Audit' + query.value = 'config' + expect(filtered.value).toHaveLength(0) + }) + + it('domains list includes "all" first then unique sorted domain names', () => { + const cells = ref(CELLS) + const { domains } = useCellsFilter(cells) + expect(domains.value[0]).toBe('all') + expect(domains.value.slice(1)).toEqual(['Access', 'Audit', 'Config']) + }) + + it('domains are unique even when cells share the same domain', () => { + const dupCells: readonly CellEntry[] = [ + makeCell({ id: 'a', domain: 'Access' }), + makeCell({ id: 'b', domain: 'Access' }), + makeCell({ id: 'c', domain: 'Audit' }), + ] + const cells = ref(dupCells) + const { domains } = useCellsFilter(cells) + const domainList = domains.value.filter((d) => d !== 'all') + expect(domainList).toEqual(['Access', 'Audit']) + }) + + it('accepts ComputedRef', () => { + const source = ref(CELLS) + const computedCells = computed(() => source.value) + const { filtered } = useCellsFilter(computedCells) + expect(filtered.value).toHaveLength(3) + }) + + it('query="" (empty string) with domain=all returns all', () => { + const cells = ref(CELLS) + const { query, filtered } = useCellsFilter(cells) + query.value = '' + expect(filtered.value).toHaveLength(3) + }) +}) diff --git a/packages/devboard/src/composables/useCellsFilter.ts b/packages/devboard/src/composables/useCellsFilter.ts new file mode 100644 index 0000000..0e3e4e0 --- /dev/null +++ b/packages/devboard/src/composables/useCellsFilter.ts @@ -0,0 +1,44 @@ +import { ref, computed } from 'vue' +import type { Ref, ComputedRef } from 'vue' +import type { CellEntry } from '../manifest/types' + +export interface CellsFilterResult { + /** The current free-text search query. */ + readonly query: Ref + /** The currently selected domain, or 'all'. */ + readonly domain: Ref + /** 'all' followed by unique sorted domain names derived from cells. */ + readonly domains: ComputedRef + /** Cells passing both the domain filter and the query filter. */ + readonly filtered: ComputedRef +} + +/** + * useCellsFilter — pure filtering composable for the Cells list page. + * + * @param cells - Reactive list of all CellEntry items to filter. + * @returns Reactive filter state + derived filtered list. + */ +export function useCellsFilter( + cells: Ref | ComputedRef, +): CellsFilterResult { + const query = ref('') + const domain = ref('all') + + const domains = computed(() => { + const unique = [...new Set(cells.value.map((c) => c.domain))].sort() + return ['all', ...unique] + }) + + const filtered = computed(() => { + const q = query.value.toLowerCase() + return cells.value.filter((c) => { + const domainMatch = domain.value === 'all' || c.domain === domain.value + const queryMatch = + q === '' || c.id.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) + return domainMatch && queryMatch + }) + }) + + return { query, domain, domains, filtered } +} diff --git a/packages/devboard/src/composables/useRovingTablist.spec.ts b/packages/devboard/src/composables/useRovingTablist.spec.ts new file mode 100644 index 0000000..7b9d545 --- /dev/null +++ b/packages/devboard/src/composables/useRovingTablist.spec.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useRovingTablist } from './useRovingTablist' + +const TAB_IDS = ['overview', 'interfaces', 'wiring'] as const + +describe('useRovingTablist', () => { + let focusMock: ReturnType + let mockEl: { focus: ReturnType } + + beforeEach(() => { + focusMock = vi.fn() + mockEl = { focus: focusMock } + vi.spyOn(document, 'getElementById').mockImplementation((id: string) => { + if (id.includes('overview') || id.includes('interfaces') || id.includes('wiring')) { + return mockEl as unknown as HTMLElement + } + return null + }) + }) + + const makeRoving = (onActivate?: (id: string) => void) => + useRovingTablist({ + tabIds: () => TAB_IDS, + idFor: (id) => `cell-tab-test-${id}`, + onActivate, + }) + + const makeKeyEvent = (key: string): KeyboardEvent => + new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + + it('ArrowRight moves focus from first to second tab', () => { + const { onKeydown } = makeRoving() + const e = makeKeyEvent('ArrowRight') + const preventSpy = vi.spyOn(e, 'preventDefault') + onKeydown(e, 'overview') + expect(preventSpy).toHaveBeenCalled() + expect(document.getElementById).toHaveBeenCalledWith('cell-tab-test-interfaces') + expect(focusMock).toHaveBeenCalled() + }) + + it('ArrowRight from last tab wraps to first', () => { + const { onKeydown } = makeRoving() + const e = makeKeyEvent('ArrowRight') + const preventSpy = vi.spyOn(e, 'preventDefault') + onKeydown(e, 'wiring') + expect(preventSpy).toHaveBeenCalled() + expect(document.getElementById).toHaveBeenCalledWith('cell-tab-test-overview') + expect(focusMock).toHaveBeenCalled() + }) + + it('ArrowLeft moves focus from last to second tab', () => { + const { onKeydown } = makeRoving() + const e = makeKeyEvent('ArrowLeft') + const preventSpy = vi.spyOn(e, 'preventDefault') + onKeydown(e, 'wiring') + expect(preventSpy).toHaveBeenCalled() + expect(document.getElementById).toHaveBeenCalledWith('cell-tab-test-interfaces') + expect(focusMock).toHaveBeenCalled() + }) + + it('ArrowLeft from first tab wraps to last', () => { + const { onKeydown } = makeRoving() + const e = makeKeyEvent('ArrowLeft') + const preventSpy = vi.spyOn(e, 'preventDefault') + onKeydown(e, 'overview') + expect(preventSpy).toHaveBeenCalled() + expect(document.getElementById).toHaveBeenCalledWith('cell-tab-test-wiring') + expect(focusMock).toHaveBeenCalled() + }) + + it('Home jumps to first tab', () => { + const { onKeydown } = makeRoving() + const e = makeKeyEvent('Home') + const preventSpy = vi.spyOn(e, 'preventDefault') + onKeydown(e, 'wiring') + expect(preventSpy).toHaveBeenCalled() + expect(document.getElementById).toHaveBeenCalledWith('cell-tab-test-overview') + expect(focusMock).toHaveBeenCalled() + }) + + it('End jumps to last tab', () => { + const { onKeydown } = makeRoving() + const e = makeKeyEvent('End') + const preventSpy = vi.spyOn(e, 'preventDefault') + onKeydown(e, 'overview') + expect(preventSpy).toHaveBeenCalled() + expect(document.getElementById).toHaveBeenCalledWith('cell-tab-test-wiring') + expect(focusMock).toHaveBeenCalled() + }) + + it('unrelated key does not call preventDefault and does not move focus', () => { + const { onKeydown } = makeRoving() + const e = makeKeyEvent('Tab') + const preventSpy = vi.spyOn(e, 'preventDefault') + onKeydown(e, 'overview') + expect(preventSpy).not.toHaveBeenCalled() + expect(focusMock).not.toHaveBeenCalled() + }) + + it('Enter key does not call preventDefault and does not move focus', () => { + const { onKeydown } = makeRoving() + const e = makeKeyEvent('Enter') + const preventSpy = vi.spyOn(e, 'preventDefault') + onKeydown(e, 'overview') + expect(preventSpy).not.toHaveBeenCalled() + expect(focusMock).not.toHaveBeenCalled() + }) + + it('onActivate is called with target id on ArrowRight', () => { + const onActivate = vi.fn() + const { onKeydown } = makeRoving(onActivate) + onKeydown(makeKeyEvent('ArrowRight'), 'overview') + expect(onActivate).toHaveBeenCalledWith('interfaces') + }) + + it('onActivate is called with target id on ArrowLeft', () => { + const onActivate = vi.fn() + const { onKeydown } = makeRoving(onActivate) + onKeydown(makeKeyEvent('ArrowLeft'), 'wiring') + expect(onActivate).toHaveBeenCalledWith('interfaces') + }) + + it('onActivate is called with target id on Home', () => { + const onActivate = vi.fn() + const { onKeydown } = makeRoving(onActivate) + onKeydown(makeKeyEvent('Home'), 'wiring') + expect(onActivate).toHaveBeenCalledWith('overview') + }) + + it('onActivate is called with target id on End', () => { + const onActivate = vi.fn() + const { onKeydown } = makeRoving(onActivate) + onKeydown(makeKeyEvent('End'), 'overview') + expect(onActivate).toHaveBeenCalledWith('wiring') + }) + + it('onActivate is NOT called on unrelated key (Tab)', () => { + const onActivate = vi.fn() + const { onKeydown } = makeRoving(onActivate) + onKeydown(makeKeyEvent('Tab'), 'overview') + expect(onActivate).not.toHaveBeenCalled() + }) + + it('onActivate is NOT called on Enter key', () => { + const onActivate = vi.fn() + const { onKeydown } = makeRoving(onActivate) + onKeydown(makeKeyEvent('Enter'), 'overview') + expect(onActivate).not.toHaveBeenCalled() + }) +}) diff --git a/packages/devboard/src/composables/useRovingTablist.ts b/packages/devboard/src/composables/useRovingTablist.ts new file mode 100644 index 0000000..ff9365c --- /dev/null +++ b/packages/devboard/src/composables/useRovingTablist.ts @@ -0,0 +1,61 @@ +/** + * useRovingTablist — ARIA APG roving tabindex for a horizontal tablist. + * + * Handles ArrowRight / ArrowLeft (with wrap) and Home / End. + * Other keys are ignored (no preventDefault, no focus move). + * + * Blinding coverage: does not handle vertical arrow keys (ArrowUp/Down), + * nor does it update tabindex attributes — that is the caller's responsibility. + */ +export interface RovingTablistOptions { + /** Returns the ordered list of tab ids (called on every keydown for live lists). */ + tabIds: () => readonly string[] + /** Maps a tab id to the DOM element id of its button. */ + idFor: (id: string) => string + /** + * Optional automatic-activation callback (APG pattern). + * Called with the target tab id after focus moves on Arrow/Home/End. + * Keeps roving "home" position in sync with the active tab. + */ + onActivate?: ((id: string) => void) | undefined +} + +export interface RovingTablistReturn { + onKeydown(e: KeyboardEvent, currentId: string): void +} + +export function useRovingTablist(opts: RovingTablistOptions): RovingTablistReturn { + function onKeydown(e: KeyboardEvent, currentId: string): void { + const ids = opts.tabIds() + const current = ids.indexOf(currentId) + if (current === -1) return + + let target = -1 + + switch (e.key) { + case 'ArrowRight': + target = (current + 1) % ids.length + break + case 'ArrowLeft': + target = (current - 1 + ids.length) % ids.length + break + case 'Home': + target = 0 + break + case 'End': + target = ids.length - 1 + break + default: + return + } + + const targetId = ids[target] + if (targetId === undefined) return + + e.preventDefault() + document.getElementById(opts.idFor(targetId))?.focus() + opts.onActivate?.(targetId) + } + + return { onKeydown } +} diff --git a/packages/devboard/src/index.ts b/packages/devboard/src/index.ts index 336ce12..bce5cf0 100644 --- a/packages/devboard/src/index.ts +++ b/packages/devboard/src/index.ts @@ -1 +1,5 @@ -export {} +export * from './manifest/types' +export { CELL_MANIFEST } from './manifest/cells.generated' +export { useCellsStore } from './stores' +export { default as CellDurabilityBadge } from './components/CellDurabilityBadge.vue' +export { default as UnavailablePanel } from './components/UnavailablePanel.vue' diff --git a/packages/devboard/src/manifest/cells.generated.ts b/packages/devboard/src/manifest/cells.generated.ts new file mode 100644 index 0000000..3709d16 --- /dev/null +++ b/packages/devboard/src/manifest/cells.generated.ts @@ -0,0 +1,1112 @@ +/* eslint-disable */ +// AUTO-GENERATED by tools/cell-manifest — DO NOT EDIT BY HAND. +// Source: gocell/cells/** +// Regenerate: pnpm cell-manifest (CI guards via git diff --exit-code) +import type { CellManifest } from './types' + +export const CELL_MANIFEST: CellManifest = { + "cells": [ + { + "id": "accesscore", + "name": "AccessCore", + "domain": "Access", + "type": "core", + "consistencyLevel": "L3", + "lifecycle": "asset", + "durabilityMode": "durable", + "owner": { + "team": "platform", + "role": "cell-owner" + }, + "goStructName": "AccessCore", + "schemaPrimary": "cell_access_core", + "requires": [ + "postgres", + "redis" + ], + "l0Dependencies": [], + "smokeTests": [ + "smoke.accesscore.startup" + ], + "slices": [ + { + "id": "authorizationdecide", + "belongsToCell": "accesscore", + "consistencyLevel": "L0", + "lifecycle": "asset", + "contractUsages": [], + "unitTests": [ + "unit.authorizationdecide.service" + ], + "contractTests": [], + "waivers": [] + }, + { + "id": "configreceive", + "belongsToCell": "accesscore", + "consistencyLevel": "L3", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "event.config.entry-upserted.v1", + "role": "subscribe" + }, + { + "contract": "event.config.entry-deleted.v1", + "role": "subscribe" + }, + { + "contract": "http.config.internal.get.v1", + "role": "call" + } + ], + "unitTests": [ + "unit.configreceive.service" + ], + "contractTests": [ + "contract.event.config.entry-upserted.v1.subscribe", + "contract.event.config.entry-deleted.v1.subscribe", + "contract.http.config.internal.get.v1.call" + ], + "waivers": [] + }, + { + "id": "identitymanage", + "belongsToCell": "accesscore", + "consistencyLevel": "L2", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "event.user.created.v1", + "role": "publish" + }, + { + "contract": "event.user.locked.v1", + "role": "publish" + }, + { + "contract": "event.user.deleted.v1", + "role": "publish" + }, + { + "contract": "event.user.updated.v1", + "role": "publish" + }, + { + "contract": "event.user.unlocked.v1", + "role": "publish" + }, + { + "contract": "http.auth.user.create.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.get.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.update.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.patch.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.delete.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.lock.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.unlock.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.change-password.v1", + "role": "serve" + } + ], + "unitTests": [ + "unit.identitymanage.service" + ], + "contractTests": [ + "contract.event.user.created.v1.publish", + "contract.event.user.locked.v1.publish", + "contract.event.user.deleted.v1.publish", + "contract.event.user.updated.v1.publish", + "contract.event.user.unlocked.v1.publish", + "contract.http.auth.user.create.v1.serve", + "contract.http.auth.user.get.v1.serve", + "contract.http.auth.user.update.v1.serve", + "contract.http.auth.user.patch.v1.serve", + "contract.http.auth.user.delete.v1.serve", + "contract.http.auth.user.lock.v1.serve", + "contract.http.auth.user.unlock.v1.serve", + "contract.http.auth.user.change-password.v1.serve" + ], + "waivers": [] + }, + { + "id": "rbacassign", + "belongsToCell": "accesscore", + "consistencyLevel": "L2", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "http.auth.role.assign.v1", + "role": "serve" + }, + { + "contract": "http.auth.role.revoke.v1", + "role": "serve" + }, + { + "contract": "event.role.assigned.v1", + "role": "publish" + }, + { + "contract": "event.role.revoked.v1", + "role": "publish" + } + ], + "unitTests": [ + "unit.rbacassign.service" + ], + "contractTests": [ + "contract.http.auth.role.assign.v1.serve", + "contract.http.auth.role.revoke.v1.serve", + "contract.event.role.assigned.v1.publish", + "contract.event.role.revoked.v1.publish" + ], + "waivers": [] + }, + { + "id": "rbaccheck", + "belongsToCell": "accesscore", + "consistencyLevel": "L0", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "http.auth.role.list.v1", + "role": "serve" + }, + { + "contract": "http.auth.role.check.v1", + "role": "serve" + } + ], + "unitTests": [ + "unit.rbaccheck.service" + ], + "contractTests": [ + "contract.http.auth.role.list.v1.serve", + "contract.http.auth.role.check.v1.serve" + ], + "waivers": [] + }, + { + "id": "sessionlogin", + "belongsToCell": "accesscore", + "consistencyLevel": "L2", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "http.auth.login.v1", + "role": "serve" + }, + { + "contract": "event.session.created.v1", + "role": "publish" + }, + { + "contract": "event.user.locked.v1", + "role": "publish" + }, + { + "contract": "event.user.unlocked.v1", + "role": "publish" + }, + { + "contract": "http.config.get.v1", + "role": "call" + } + ], + "unitTests": [ + "unit.sessionlogin.service" + ], + "contractTests": [ + "contract.http.auth.login.v1.serve", + "contract.event.session.created.v1.publish", + "contract.event.user.locked.v1.publish", + "contract.event.user.unlocked.v1.publish" + ], + "waivers": [ + { + "contract": "http.config.get.v1", + "owner": "platform-team", + "reason": "只读配置调用,集成测试已覆盖;waiver 由 #1467 续期解阻塞 develop CI,补测试还债见 #1472", + "expiresAt": "2026-09-01" + } + ] + }, + { + "id": "sessionlogout", + "belongsToCell": "accesscore", + "consistencyLevel": "L2", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "http.auth.session.delete.v1", + "role": "serve" + }, + { + "contract": "event.session.revoked.v1", + "role": "publish" + }, + { + "contract": "event.role.assigned.v1", + "role": "subscribe" + }, + { + "contract": "event.role.revoked.v1", + "role": "subscribe" + } + ], + "unitTests": [ + "unit.sessionlogout.service" + ], + "contractTests": [ + "contract.http.auth.session.delete.v1.serve", + "contract.event.session.revoked.v1.publish" + ], + "waivers": [ + { + "contract": "event.role.assigned.v1", + "owner": "platform-team", + "reason": "consumer contract test tracked under S8-FOLLOWUP; handler tested by consumer_test.go", + "expiresAt": "2026-07-01" + }, + { + "contract": "event.role.revoked.v1", + "owner": "platform-team", + "reason": "consumer contract test tracked under S8-FOLLOWUP; handler tested by consumer_test.go", + "expiresAt": "2026-07-01" + } + ] + }, + { + "id": "sessionrefresh", + "belongsToCell": "accesscore", + "consistencyLevel": "L1", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "http.auth.refresh.v1", + "role": "serve" + } + ], + "unitTests": [ + "unit.sessionrefresh.service" + ], + "contractTests": [ + "contract.http.auth.refresh.v1.serve" + ], + "waivers": [] + }, + { + "id": "sessionvalidate", + "belongsToCell": "accesscore", + "consistencyLevel": "L0", + "lifecycle": "asset", + "contractUsages": [], + "unitTests": [ + "unit.sessionvalidate.service" + ], + "contractTests": [], + "waivers": [] + }, + { + "id": "setup", + "belongsToCell": "accesscore", + "consistencyLevel": "L2", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "http.auth.setup.status.v1", + "role": "serve" + }, + { + "contract": "http.auth.setup.admin.v1", + "role": "serve" + }, + { + "contract": "event.user.created.v1", + "role": "publish" + }, + { + "contract": "event.auth.bootstrap-failed.v1", + "role": "publish" + } + ], + "unitTests": [ + "unit.setup.service" + ], + "contractTests": [ + "contract.http.auth.setup.status.v1.serve", + "contract.http.auth.setup.admin.v1.serve", + "contract.event.user.created.v1.publish", + "contract.event.auth.bootstrap-failed.v1.publish" + ], + "waivers": [] + } + ], + "produces": [ + { + "contract": "event.auth.bootstrap-failed.v1", + "role": "publish" + }, + { + "contract": "event.role.assigned.v1", + "role": "publish" + }, + { + "contract": "event.role.revoked.v1", + "role": "publish" + }, + { + "contract": "event.session.created.v1", + "role": "publish" + }, + { + "contract": "event.session.revoked.v1", + "role": "publish" + }, + { + "contract": "event.user.created.v1", + "role": "publish" + }, + { + "contract": "event.user.deleted.v1", + "role": "publish" + }, + { + "contract": "event.user.locked.v1", + "role": "publish" + }, + { + "contract": "event.user.unlocked.v1", + "role": "publish" + }, + { + "contract": "event.user.updated.v1", + "role": "publish" + }, + { + "contract": "http.auth.login.v1", + "role": "serve" + }, + { + "contract": "http.auth.refresh.v1", + "role": "serve" + }, + { + "contract": "http.auth.role.assign.v1", + "role": "serve" + }, + { + "contract": "http.auth.role.check.v1", + "role": "serve" + }, + { + "contract": "http.auth.role.list.v1", + "role": "serve" + }, + { + "contract": "http.auth.role.revoke.v1", + "role": "serve" + }, + { + "contract": "http.auth.session.delete.v1", + "role": "serve" + }, + { + "contract": "http.auth.setup.admin.v1", + "role": "serve" + }, + { + "contract": "http.auth.setup.status.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.change-password.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.create.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.delete.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.get.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.lock.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.patch.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.unlock.v1", + "role": "serve" + }, + { + "contract": "http.auth.user.update.v1", + "role": "serve" + } + ], + "consumes": [ + { + "contract": "event.config.entry-deleted.v1", + "role": "subscribe" + }, + { + "contract": "event.config.entry-upserted.v1", + "role": "subscribe" + }, + { + "contract": "event.role.assigned.v1", + "role": "subscribe" + }, + { + "contract": "event.role.revoked.v1", + "role": "subscribe" + }, + { + "contract": "http.config.get.v1", + "role": "call" + }, + { + "contract": "http.config.internal.get.v1", + "role": "call" + } + ], + "dependsOnCells": [ + "configcore" + ], + "requiredByCells": [ + "auditcore" + ] + }, + { + "id": "auditcore", + "name": "AuditCore", + "domain": "Audit", + "type": "core", + "consistencyLevel": "L2", + "lifecycle": "asset", + "durabilityMode": "durable", + "owner": { + "team": "platform", + "role": "cell-owner" + }, + "goStructName": "AuditCore", + "schemaPrimary": "cell_audit_core", + "requires": [ + "postgres" + ], + "l0Dependencies": [], + "smokeTests": [ + "smoke.auditcore.startup" + ], + "slices": [ + { + "id": "auditappendbootstrap", + "belongsToCell": "auditcore", + "consistencyLevel": "L1", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "event.auth.bootstrap-failed.v1", + "role": "subscribe" + } + ], + "unitTests": [ + "unit.auditappendbootstrap.service" + ], + "contractTests": [ + "contract.event.auth.bootstrap-failed.v1.subscribe" + ], + "waivers": [] + }, + { + "id": "auditappendconfig", + "belongsToCell": "auditcore", + "consistencyLevel": "L2", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "event.audit.appended.v1", + "role": "publish" + }, + { + "contract": "event.config.entry-upserted.v1", + "role": "subscribe" + }, + { + "contract": "event.config.entry-deleted.v1", + "role": "subscribe" + }, + { + "contract": "event.config.version-published.v1", + "role": "subscribe" + }, + { + "contract": "event.config.rollback.v1", + "role": "subscribe" + } + ], + "unitTests": [ + "unit.auditappendconfig.service" + ], + "contractTests": [ + "contract.event.audit.appended.v1.publish", + "contract.event.config.entry-upserted.v1.subscribe", + "contract.event.config.entry-deleted.v1.subscribe", + "contract.event.config.version-published.v1.subscribe", + "contract.event.config.rollback.v1.subscribe" + ], + "waivers": [] + }, + { + "id": "auditappendrole", + "belongsToCell": "auditcore", + "consistencyLevel": "L2", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "event.audit.appended.v1", + "role": "publish" + }, + { + "contract": "event.role.assigned.v1", + "role": "subscribe" + }, + { + "contract": "event.role.revoked.v1", + "role": "subscribe" + } + ], + "unitTests": [ + "unit.auditappendrole.service" + ], + "contractTests": [ + "contract.event.audit.appended.v1.publish", + "contract.event.role.assigned.v1.subscribe", + "contract.event.role.revoked.v1.subscribe" + ], + "waivers": [] + }, + { + "id": "auditappendsession", + "belongsToCell": "auditcore", + "consistencyLevel": "L2", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "event.audit.appended.v1", + "role": "publish" + }, + { + "contract": "event.session.created.v1", + "role": "subscribe" + }, + { + "contract": "event.session.revoked.v1", + "role": "subscribe" + } + ], + "unitTests": [ + "unit.auditappendsession.service" + ], + "contractTests": [ + "contract.event.audit.appended.v1.publish", + "contract.event.session.created.v1.subscribe", + "contract.event.session.revoked.v1.subscribe" + ], + "waivers": [] + }, + { + "id": "auditappenduser", + "belongsToCell": "auditcore", + "consistencyLevel": "L2", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "event.audit.appended.v1", + "role": "publish" + }, + { + "contract": "event.user.created.v1", + "role": "subscribe" + }, + { + "contract": "event.user.locked.v1", + "role": "subscribe" + }, + { + "contract": "event.user.unlocked.v1", + "role": "subscribe" + }, + { + "contract": "event.user.updated.v1", + "role": "subscribe" + }, + { + "contract": "event.user.deleted.v1", + "role": "subscribe" + } + ], + "unitTests": [ + "unit.auditappenduser.service" + ], + "contractTests": [ + "contract.event.audit.appended.v1.publish", + "contract.event.user.created.v1.subscribe", + "contract.event.user.locked.v1.subscribe", + "contract.event.user.unlocked.v1.subscribe", + "contract.event.user.updated.v1.subscribe", + "contract.event.user.deleted.v1.subscribe" + ], + "waivers": [] + }, + { + "id": "auditquery", + "belongsToCell": "auditcore", + "consistencyLevel": "L0", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "http.audit.list.v1", + "role": "serve" + } + ], + "unitTests": [ + "unit.auditquery.service" + ], + "contractTests": [ + "contract.http.audit.list.v1.serve" + ], + "waivers": [] + } + ], + "produces": [ + { + "contract": "event.audit.appended.v1", + "role": "publish" + }, + { + "contract": "http.audit.list.v1", + "role": "serve" + } + ], + "consumes": [ + { + "contract": "event.auth.bootstrap-failed.v1", + "role": "subscribe" + }, + { + "contract": "event.config.entry-deleted.v1", + "role": "subscribe" + }, + { + "contract": "event.config.entry-upserted.v1", + "role": "subscribe" + }, + { + "contract": "event.config.rollback.v1", + "role": "subscribe" + }, + { + "contract": "event.config.version-published.v1", + "role": "subscribe" + }, + { + "contract": "event.role.assigned.v1", + "role": "subscribe" + }, + { + "contract": "event.role.revoked.v1", + "role": "subscribe" + }, + { + "contract": "event.session.created.v1", + "role": "subscribe" + }, + { + "contract": "event.session.revoked.v1", + "role": "subscribe" + }, + { + "contract": "event.user.created.v1", + "role": "subscribe" + }, + { + "contract": "event.user.deleted.v1", + "role": "subscribe" + }, + { + "contract": "event.user.locked.v1", + "role": "subscribe" + }, + { + "contract": "event.user.unlocked.v1", + "role": "subscribe" + }, + { + "contract": "event.user.updated.v1", + "role": "subscribe" + } + ], + "dependsOnCells": [ + "accesscore", + "configcore" + ], + "requiredByCells": [] + }, + { + "id": "configcore", + "name": "ConfigCore", + "domain": "Config", + "type": "core", + "consistencyLevel": "L3", + "lifecycle": "asset", + "durabilityMode": "durable", + "owner": { + "team": "platform", + "role": "cell-owner" + }, + "goStructName": "ConfigCore", + "schemaPrimary": "cell_config_core", + "requires": [ + "postgres" + ], + "l0Dependencies": [], + "smokeTests": [ + "smoke.configcore.startup" + ], + "slices": [ + { + "id": "configpublish", + "belongsToCell": "configcore", + "consistencyLevel": "L2", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "http.config.publish.v1", + "role": "serve" + }, + { + "contract": "http.config.rollback.v1", + "role": "serve" + }, + { + "contract": "event.config.entry-upserted.v1", + "role": "publish" + }, + { + "contract": "event.config.version-published.v1", + "role": "publish" + }, + { + "contract": "event.config.rollback.v1", + "role": "publish" + } + ], + "unitTests": [ + "unit.configpublish.service" + ], + "contractTests": [ + "contract.http.config.publish.v1.serve", + "contract.http.config.rollback.v1.serve", + "contract.event.config.entry-upserted.v1.publish", + "contract.event.config.version-published.v1.publish", + "contract.event.config.rollback.v1.publish" + ], + "waivers": [] + }, + { + "id": "configread", + "belongsToCell": "configcore", + "consistencyLevel": "L0", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "http.config.get.v1", + "role": "serve" + }, + { + "contract": "http.config.list.v1", + "role": "serve" + } + ], + "unitTests": [ + "unit.configread.service" + ], + "contractTests": [ + "contract.http.config.get.v1.serve", + "contract.http.config.list.v1.serve" + ], + "waivers": [] + }, + { + "id": "configreadinternal", + "belongsToCell": "configcore", + "consistencyLevel": "L0", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "http.config.internal.get.v1", + "role": "serve" + } + ], + "unitTests": [ + "unit.configreadinternal.service" + ], + "contractTests": [ + "contract.http.config.internal.get.v1.serve" + ], + "waivers": [] + }, + { + "id": "configsubscribe", + "belongsToCell": "configcore", + "consistencyLevel": "L3", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "event.config.entry-upserted.v1", + "role": "subscribe" + }, + { + "contract": "event.config.entry-deleted.v1", + "role": "subscribe" + } + ], + "unitTests": [ + "unit.configsubscribe.service" + ], + "contractTests": [ + "contract.event.config.entry-upserted.v1.subscribe", + "contract.event.config.entry-deleted.v1.subscribe" + ], + "waivers": [] + }, + { + "id": "configwrite", + "belongsToCell": "configcore", + "consistencyLevel": "L2", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "http.config.write.v1", + "role": "serve" + }, + { + "contract": "http.config.update.v1", + "role": "serve" + }, + { + "contract": "http.config.delete.v1", + "role": "serve" + }, + { + "contract": "event.config.entry-upserted.v1", + "role": "publish" + }, + { + "contract": "event.config.entry-deleted.v1", + "role": "publish" + } + ], + "unitTests": [ + "unit.configwrite.service" + ], + "contractTests": [ + "contract.http.config.write.v1.serve", + "contract.http.config.update.v1.serve", + "contract.http.config.delete.v1.serve", + "contract.event.config.entry-upserted.v1.publish", + "contract.event.config.entry-deleted.v1.publish" + ], + "waivers": [] + }, + { + "id": "featureflag", + "belongsToCell": "configcore", + "consistencyLevel": "L0", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "http.config.flags.list.v1", + "role": "serve" + }, + { + "contract": "http.config.flags.get.v1", + "role": "serve" + }, + { + "contract": "http.config.flags.evaluate.v1", + "role": "serve" + } + ], + "unitTests": [ + "unit.featureflag.service" + ], + "contractTests": [ + "contract.http.config.flags.list.v1.serve", + "contract.http.config.flags.get.v1.serve", + "contract.http.config.flags.evaluate.v1.serve" + ], + "waivers": [] + }, + { + "id": "flagwrite", + "belongsToCell": "configcore", + "consistencyLevel": "L1", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "http.config.flags.create.v1", + "role": "serve" + }, + { + "contract": "http.config.flags.update.v1", + "role": "serve" + }, + { + "contract": "http.config.flags.toggle.v1", + "role": "serve" + }, + { + "contract": "http.config.flags.delete.v1", + "role": "serve" + } + ], + "unitTests": [ + "unit.flagwrite.service" + ], + "contractTests": [ + "contract.http.config.flags.create.v1.serve", + "contract.http.config.flags.update.v1.serve", + "contract.http.config.flags.toggle.v1.serve", + "contract.http.config.flags.delete.v1.serve" + ], + "waivers": [] + } + ], + "produces": [ + { + "contract": "event.config.entry-deleted.v1", + "role": "publish" + }, + { + "contract": "event.config.entry-upserted.v1", + "role": "publish" + }, + { + "contract": "event.config.rollback.v1", + "role": "publish" + }, + { + "contract": "event.config.version-published.v1", + "role": "publish" + }, + { + "contract": "http.config.delete.v1", + "role": "serve" + }, + { + "contract": "http.config.flags.create.v1", + "role": "serve" + }, + { + "contract": "http.config.flags.delete.v1", + "role": "serve" + }, + { + "contract": "http.config.flags.evaluate.v1", + "role": "serve" + }, + { + "contract": "http.config.flags.get.v1", + "role": "serve" + }, + { + "contract": "http.config.flags.list.v1", + "role": "serve" + }, + { + "contract": "http.config.flags.toggle.v1", + "role": "serve" + }, + { + "contract": "http.config.flags.update.v1", + "role": "serve" + }, + { + "contract": "http.config.get.v1", + "role": "serve" + }, + { + "contract": "http.config.internal.get.v1", + "role": "serve" + }, + { + "contract": "http.config.list.v1", + "role": "serve" + }, + { + "contract": "http.config.publish.v1", + "role": "serve" + }, + { + "contract": "http.config.rollback.v1", + "role": "serve" + }, + { + "contract": "http.config.update.v1", + "role": "serve" + }, + { + "contract": "http.config.write.v1", + "role": "serve" + } + ], + "consumes": [ + { + "contract": "event.config.entry-deleted.v1", + "role": "subscribe" + }, + { + "contract": "event.config.entry-upserted.v1", + "role": "subscribe" + } + ], + "dependsOnCells": [], + "requiredByCells": [ + "accesscore", + "auditcore" + ] + } + ], + "generatedFrom": "gocell/cells/**/{cell.yaml,slices/*/slice.yaml}" +} diff --git a/packages/devboard/src/manifest/types.ts b/packages/devboard/src/manifest/types.ts new file mode 100644 index 0000000..67fa9ac --- /dev/null +++ b/packages/devboard/src/manifest/types.ts @@ -0,0 +1,52 @@ +export type ConsistencyLevel = 'L0' | 'L1' | 'L2' | 'L3' +export type DurabilityMode = 'durable' | 'demo' +export type ContractRole = 'serve' | 'call' | 'publish' | 'subscribe' + +export interface ContractUsage { + readonly contract: string + readonly role: ContractRole +} + +export interface CellWaiver { + readonly contract: string + readonly owner: string + readonly reason: string + readonly expiresAt: string +} + +export interface CellSlice { + readonly id: string + readonly belongsToCell: string + readonly consistencyLevel: ConsistencyLevel + readonly lifecycle: string + readonly contractUsages: readonly ContractUsage[] + readonly unitTests: readonly string[] + readonly contractTests: readonly string[] + readonly waivers: readonly CellWaiver[] +} + +export interface CellEntry { + readonly id: string + readonly name: string // = goStructName + readonly domain: string // derived: id minus trailing 'core', title-cased + readonly type: string + readonly consistencyLevel: ConsistencyLevel + readonly lifecycle: string + readonly durabilityMode: DurabilityMode + readonly owner: { readonly team: string; readonly role: string } + readonly goStructName: string + readonly schemaPrimary: string | null + readonly requires: readonly string[] + readonly l0Dependencies: readonly string[] + readonly smokeTests: readonly string[] + readonly slices: readonly CellSlice[] + readonly produces: readonly ContractUsage[] // role serve|publish, deduped by contract, sorted by contract + readonly consumes: readonly ContractUsage[] // role call|subscribe, deduped by contract, sorted by contract + readonly dependsOnCells: readonly string[] // derived cross-cell, sorted, self excluded + readonly requiredByCells: readonly string[] // reverse, sorted +} + +export interface CellManifest { + readonly cells: readonly CellEntry[] // sorted by id + readonly generatedFrom: string // static note, NOT a timestamp (determinism) +} diff --git a/packages/devboard/src/stores/index.ts b/packages/devboard/src/stores/index.ts new file mode 100644 index 0000000..4082bd4 --- /dev/null +++ b/packages/devboard/src/stores/index.ts @@ -0,0 +1 @@ +export { useCellsStore } from './useCellsStore' diff --git a/packages/devboard/src/stores/useCellsStore.spec.ts b/packages/devboard/src/stores/useCellsStore.spec.ts new file mode 100644 index 0000000..52722e8 --- /dev/null +++ b/packages/devboard/src/stores/useCellsStore.spec.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useCellsStore } from './useCellsStore' +import { CELL_MANIFEST } from '../manifest/cells.generated' + +describe('useCellsStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('initial selectedId is null', () => { + const store = useCellsStore() + expect(store.selectedId).toBeNull() + }) + + it('cells returns all manifest cells (length 3)', () => { + const store = useCellsStore() + expect(store.cells).toBe(CELL_MANIFEST.cells) + expect(store.cells.length).toBe(3) + }) + + it('byId returns a known cell entry', () => { + const store = useCellsStore() + const cell = store.byId('accesscore') + expect(cell).not.toBeNull() + expect(cell?.id).toBe('accesscore') + expect(cell?.domain).toBe('Access') + }) + + it('byId returns null for unknown id', () => { + const store = useCellsStore() + expect(store.byId('does-not-exist')).toBeNull() + }) + + it('selectCell sets selectedId and selectedCell', () => { + const store = useCellsStore() + store.selectCell('auditcore') + expect(store.selectedId).toBe('auditcore') + expect(store.selectedCell?.id).toBe('auditcore') + expect(store.selectedCell?.domain).toBe('Audit') + }) + + it('selectCell(null) clears selectedCell', () => { + const store = useCellsStore() + store.selectCell('configcore') + store.selectCell(null) + expect(store.selectedId).toBeNull() + expect(store.selectedCell).toBeNull() + }) + + it('selectedCell is null when selectedId not in manifest', () => { + const store = useCellsStore() + // Bypass action and set a nonexistent id directly to test guard + store.selectCell('ghost-cell') + expect(store.selectedCell).toBeNull() + }) +}) diff --git a/packages/devboard/src/stores/useCellsStore.ts b/packages/devboard/src/stores/useCellsStore.ts new file mode 100644 index 0000000..32d568b --- /dev/null +++ b/packages/devboard/src/stores/useCellsStore.ts @@ -0,0 +1,107 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { CELL_MANIFEST } from '../manifest/cells.generated' +import type { CellEntry } from '../manifest/types' +import { fetchCellAudit, type CellAuditEntry } from '../api/cellAudit' +import { fetchCellConfig, type CellConfigEntry } from '../api/cellConfig' + +/** Load status for the cached recent-entries panels (Audit / Config tabs). */ +type RecentLoadStatus = 'idle' | 'loading' | 'loaded' | 'error' + +/** + * devboard.cells — cell manifest query state. + * + * Wraps the static CELL_MANIFEST (build-time derived from cell.yaml files) + * with reactive selection state for the Cells detail panel. + */ +export const useCellsStore = defineStore('devboard.cells', () => { + // ─── state ────────────────────────────────────────────────────────────── + const selectedId = ref(null) + + // id → entry lookup, derived once from the (static) manifest list. The + // generated manifest ships only `cells`; the index is built here at runtime + // rather than duplicating every entry into a second `byId` literal. + const byIdMap = new Map(CELL_MANIFEST.cells.map((c) => [c.id, c])) + + // ─── getters ──────────────────────────────────────────────────────────── + + /** All cells from the manifest, sorted by id (as generated). */ + const cells = computed(() => CELL_MANIFEST.cells) + + /** + * Look up a cell by id. Returns null if not found. + * Exposed as a function rather than a computed map to allow + * per-call usage without requiring the caller to unwrap a ref. + */ + function byId(id: string): CellEntry | null { + return byIdMap.get(id) ?? null + } + + /** The currently selected cell, or null if none selected. */ + const selectedCell = computed(() => { + if (selectedId.value === null) return null + return byIdMap.get(selectedId.value) ?? null + }) + + // ─── recent Audit/Config cache ──────────────────────────────────────────── + // The cell-detail view renders only the active tabpanel, so switching away + // unmounts the Audit/Config tab and switching back remounts it. Caching the + // recent-entries lists here (rather than fetching in each tab's onMounted) + // means re-entry reuses the data instead of re-firing the request. The + // backend has no per-cell filter (BR gap), so these are global recent + // entries — a single session-level cache, no per-cell keying. + + const auditEntries = ref([]) + const auditStatus = ref('idle') + const configEntries = ref([]) + const configStatus = ref('idle') + + // ─── actions ──────────────────────────────────────────────────────────── + + function selectCell(id: string | null): void { + selectedId.value = id + } + + /** Fetch recent audit entries once per session; retries after an error. */ + async function loadRecentAudit(): Promise { + if (auditStatus.value === 'loading' || auditStatus.value === 'loaded') return + auditStatus.value = 'loading' + try { + const res = await fetchCellAudit({ limit: 20 }) + auditEntries.value = res.data + auditStatus.value = 'loaded' + } catch { + auditStatus.value = 'error' + } + } + + /** Fetch recent config entries once per session; retries after an error. */ + async function loadRecentConfig(): Promise { + if (configStatus.value === 'loading' || configStatus.value === 'loaded') return + configStatus.value = 'loading' + try { + const res = await fetchCellConfig({ limit: 50 }) + configEntries.value = res.data + configStatus.value = 'loaded' + } catch { + configStatus.value = 'error' + } + } + + return { + // state + selectedId, + auditEntries, + auditStatus, + configEntries, + configStatus, + // getters + cells, + byId, + selectedCell, + // actions + selectCell, + loadRecentAudit, + loadRecentConfig, + } +}) diff --git a/packages/devboard/src/views/CellDetailView.spec.ts b/packages/devboard/src/views/CellDetailView.spec.ts new file mode 100644 index 0000000..fc339e5 --- /dev/null +++ b/packages/devboard/src/views/CellDetailView.spec.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createTestPinia, createTestRouter } from '@gocell/core/testing' +import type { Router } from 'vue-router' +import CellDetailView from './CellDetailView.vue' + +// t returns the key (+ serialised params) so assertions are i18n-agnostic. +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (k: string, p?: Record) => (p ? `${k}:${JSON.stringify(p)}` : k), + }), +})) + +function makeRouter(): Router { + return createTestRouter([ + { path: '/cells', name: 'cells', component: { template: '
' } }, + { path: '/cells/:id', name: 'cell-detail', component: CellDetailView }, + ]) +} + +async function mountAt(path: string) { + const router = makeRouter() + await router.push(path) + await router.isReady() + const wrapper = mount(CellDetailView, { + global: { plugins: [router, createTestPinia({ createSpy: vi.fn })] }, + }) + await flushPromises() + return { wrapper, router } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('CellDetailView · known cell', () => { + it('renders the ARIA tablist with all 12 tabs', async () => { + const { wrapper } = await mountAt('/cells/accesscore') + expect(wrapper.find('[role="tablist"]').exists()).toBe(true) + expect(wrapper.findAll('[role="tab"]')).toHaveLength(12) + }) + + it('renders the cell id and durability badge in the header', async () => { + const { wrapper } = await mountAt('/cells/accesscore') + expect(wrapper.find('.cell-detail__id').text()).toBe('accesscore') + // CellDurabilityBadge renders the badge label key (accesscore is durable) + expect(wrapper.text()).toContain('cells.badge.durable') + }) + + it('defaults to the overview tab (aria-selected) when no ?tab=', async () => { + const { wrapper } = await mountAt('/cells/accesscore') + const overview = wrapper.get('#cell-tab-accesscore-overview') + expect(overview.attributes('aria-selected')).toBe('true') + const panel = wrapper.get('[role="tabpanel"]') + expect(panel.attributes('id')).toBe('cell-panel-accesscore-overview') + expect(panel.attributes('aria-labelledby')).toBe('cell-tab-accesscore-overview') + }) + + it('honours a valid ?tab= query', async () => { + const { wrapper } = await mountAt('/cells/accesscore?tab=contracts') + expect(wrapper.get('#cell-tab-accesscore-contracts').attributes('aria-selected')).toBe('true') + expect(wrapper.get('[role="tabpanel"]').attributes('id')).toBe( + 'cell-panel-accesscore-contracts', + ) + }) + + it('falls back to overview for an unknown ?tab=', async () => { + const { wrapper } = await mountAt('/cells/accesscore?tab=bogus') + expect(wrapper.get('#cell-tab-accesscore-overview').attributes('aria-selected')).toBe('true') + }) + + it('selecting a tab pushes the new ?tab= via router.replace', async () => { + const { wrapper, router } = await mountAt('/cells/accesscore') + const replaceSpy = vi.spyOn(router, 'replace') + await wrapper.get('#cell-tab-accesscore-inventory').trigger('click') + expect(replaceSpy).toHaveBeenCalledWith( + expect.objectContaining({ query: expect.objectContaining({ tab: 'inventory' }) }), + ) + }) + + it('the tabpanel is focusable (tabindex -1)', async () => { + const { wrapper } = await mountAt('/cells/accesscore') + expect(wrapper.get('[role="tabpanel"]').attributes('tabindex')).toBe('-1') + }) +}) + +describe('CellDetailView · unknown cell', () => { + it('renders a not-found alert with no tab chrome', async () => { + const { wrapper } = await mountAt('/cells/does-not-exist') + const alert = wrapper.find('[role="alert"]') + expect(alert.exists()).toBe(true) + expect(wrapper.find('[role="tablist"]').exists()).toBe(false) + expect(wrapper.text()).toContain('cells.detail.notFound') + }) + + it('not-found state offers a back link to /cells', async () => { + const { wrapper } = await mountAt('/cells/does-not-exist') + const back = wrapper.find('a[href="/cells"]') + expect(back.exists()).toBe(true) + }) +}) diff --git a/packages/devboard/src/views/CellDetailView.vue b/packages/devboard/src/views/CellDetailView.vue new file mode 100644 index 0000000..93af59e --- /dev/null +++ b/packages/devboard/src/views/CellDetailView.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/packages/devboard/src/views/CellsListView.spec.ts b/packages/devboard/src/views/CellsListView.spec.ts new file mode 100644 index 0000000..ed2952c --- /dev/null +++ b/packages/devboard/src/views/CellsListView.spec.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { defineComponent } from 'vue' +import CellsListView from './CellsListView.vue' +import type { CellEntry } from '../manifest/types' +import { useCellsStore } from '../stores/useCellsStore' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (k: string, params?: Record) => { + if (params !== undefined) return `${k}(${JSON.stringify(params)})` + return k + }, + }), +})) + +// Mock useCellsStore so we control `cells` in the view (the real store reads +// the static generated manifest which has all cells as 'durable'). +vi.mock('../stores/useCellsStore', () => ({ + useCellsStore: vi.fn(), +})) + +// Stub RouterLink to a simple so href is testable without a router +const RouterLinkStub = defineComponent({ + name: 'RouterLink', + props: { to: { type: String, required: true } }, + template: '', +}) + +const makeCell = (overrides: Partial): CellEntry => ({ + id: 'testcore', + name: 'TestCore', + domain: 'Test', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'TestCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], + ...overrides, +}) + +const STUB_CELLS: CellEntry[] = [ + makeCell({ id: 'accesscore', name: 'AccessCore', domain: 'Access', durabilityMode: 'durable' }), + makeCell({ id: 'auditcore', name: 'AuditCore', domain: 'Audit', durabilityMode: 'durable' }), + makeCell({ id: 'configcore', name: 'ConfigCore', domain: 'Config', durabilityMode: 'demo' }), +] + +function setupStoreMock(cells: CellEntry[] = STUB_CELLS): void { + // Return cells as a plain array — in the real store, Pinia's reactive proxy + // auto-unwraps ComputedRef, so `store.cells` is already readonly CellEntry[]. + vi.mocked(useCellsStore).mockReturnValue({ + cells: cells as unknown as ReturnType['cells'], + selectedId: null, + selectedCell: null, + byId: (id: string) => cells.find((c) => c.id === id) ?? null, + selectCell: vi.fn(), + $id: 'devboard.cells', + $patch: vi.fn(), + $reset: vi.fn(), + $subscribe: vi.fn(), + $onAction: vi.fn(), + $dispose: vi.fn(), + } as unknown as ReturnType) +} + +function mountView(cells: CellEntry[] = STUB_CELLS) { + setupStoreMock(cells) + return mount(CellsListView, { + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, + stubActions: false, + }), + ], + stubs: { + RouterLink: RouterLinkStub, + CellDurabilityBadge: { + name: 'CellDurabilityBadge', + props: ['mode'], + template: '{{ mode }}', + }, + }, + }, + }) +} + +describe('CellsListView', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders a table row for each cell from the store', () => { + const wrapper = mountView() + // The real store reads the static manifest which has 3 cells + const rows = wrapper.findAll('tbody tr') + expect(rows.length).toBe(3) + }) + + it('renders the CellDurabilityBadge for each row', () => { + const wrapper = mountView() + const badges = wrapper.findAll('.badge-stub') + expect(badges.length).toBe(3) + }) + + it('thead th elements all have scope="col"', () => { + const wrapper = mountView() + const headers = wrapper.findAll('thead th') + expect(headers.length).toBeGreaterThan(0) + headers.forEach((th) => { + expect(th.attributes('scope')).toBe('col') + }) + }) + + it('search input has an associated label (for/id pair)', () => { + const wrapper = mountView() + const input = wrapper.find('input[type="search"], input[type="text"]') + expect(input.exists()).toBe(true) + const inputId = input.attributes('id') + expect(inputId).toBeTruthy() + const label = wrapper.find(`label[for="${inputId}"]`) + expect(label.exists()).toBe(true) + }) + + it('typing a query filters the displayed rows', async () => { + const wrapper = mountView() + const input = wrapper.find('input[type="search"], input[type="text"]') + await input.setValue('audit') + const rows = wrapper.findAll('tbody tr') + // Only auditcore should match + expect(rows.length).toBe(1) + expect(wrapper.text()).toContain('auditcore') + }) + + it('clicking a domain button filters rows to that domain', async () => { + const wrapper = mountView() + // Find the button for 'Audit' domain + const domainBtn = wrapper.findAll('button').find((btn) => btn.text() === 'Audit') + expect(domainBtn).toBeDefined() + await domainBtn!.trigger('click') + const rows = wrapper.findAll('tbody tr') + expect(rows.length).toBe(1) + expect(wrapper.text()).toContain('auditcore') + }) + + it('active domain button has aria-pressed="true"', async () => { + const wrapper = mountView() + // The 'all' button renders as t('cells.list.domainAll') which returns the key in the mock + const allBtn = wrapper.findAll('button').find((btn) => btn.text() === 'cells.list.domainAll') + expect(allBtn).toBeDefined() + expect(allBtn!.attributes('aria-pressed')).toBe('true') + }) + + it('non-active domain buttons have aria-pressed="false"', async () => { + const wrapper = mountView() + const auditBtn = wrapper.findAll('button').find((btn) => btn.text() === 'Audit') + expect(auditBtn).toBeDefined() + expect(auditBtn!.attributes('aria-pressed')).toBe('false') + }) + + it('first cell column contains a link to /cells/:id', () => { + const wrapper = mountView() + const firstRow = wrapper.find('tbody tr') + // The link is in the first td + const link = firstRow.find('a') + expect(link.exists()).toBe(true) + expect(link.attributes('href')).toMatch(/^\/cells\//) + }) + + it('shows the empty state message when no rows match', async () => { + const wrapper = mountView() + const input = wrapper.find('input[type="search"], input[type="text"]') + await input.setValue('zzz-no-match') + const rows = wrapper.findAll('tbody tr') + // Only the empty-state row should be present + expect(rows.length).toBe(1) + expect(wrapper.text()).toContain('cells.list.empty') + }) + + it('renders the demo badge for configcore (durabilityMode=demo)', () => { + const wrapper = mountView() + const badges = wrapper.findAll('.badge-stub') + const demoBadge = badges.find((b) => b.attributes('data-mode') === 'demo') + expect(demoBadge).toBeDefined() + }) +}) diff --git a/packages/devboard/src/views/CellsListView.vue b/packages/devboard/src/views/CellsListView.vue new file mode 100644 index 0000000..d272e61 --- /dev/null +++ b/packages/devboard/src/views/CellsListView.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/packages/devboard/tsconfig.json b/packages/devboard/tsconfig.json index 564a599..1da8cb9 100644 --- a/packages/devboard/tsconfig.json +++ b/packages/devboard/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.base.json", - "include": ["src"] + "include": ["src", "vitest.config.ts"] } diff --git a/packages/devboard/vitest.config.ts b/packages/devboard/vitest.config.ts new file mode 100644 index 0000000..aa38237 --- /dev/null +++ b/packages/devboard/vitest.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + test: { + environment: 'jsdom', + include: ['src/**/*.spec.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts', 'src/**/*.vue'], + exclude: [ + 'src/**/*.spec.ts', + 'src/**/__tests__/**', + // Barrel re-export files — no logic to test + 'src/index.ts', + 'src/stores/index.ts', + // Generated file — no test needed + 'src/manifest/cells.generated.ts', + ], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77aaefb..a91e4fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,7 +26,7 @@ catalogs: version: 5.2.1 '@vitest/coverage-v8': specifier: ^3.0.4 - version: 3.0.4 + version: 3.2.6 '@vue/test-utils': specifier: ^2.4.6 version: 2.4.10 @@ -92,7 +92,7 @@ catalogs: version: 6.4.2 vitest: specifier: ^3.0.4 - version: 3.0.4 + version: 3.2.6 vue: specifier: ^3.5.13 version: 3.5.35 @@ -108,6 +108,9 @@ catalogs: vue-tsc: specifier: ^2.2.0 version: 2.2.0 + yaml: + specifier: ^2.6.0 + version: 2.9.0 importers: @@ -121,7 +124,7 @@ importers: version: 1.60.0 '@vitest/coverage-v8': specifier: 'catalog:' - version: 3.0.4(vitest@3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1)) + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0)) '@vue/test-utils': specifier: 'catalog:' version: 2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@5.7.3)))(vue@3.5.35(typescript@5.7.3)) @@ -163,7 +166,7 @@ importers: version: 8.60.0(eslint@9.39.4)(typescript@5.7.3) vitest: specifier: 'catalog:' - version: 3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) + version: 3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) vue-eslint-parser: specifier: 'catalog:' version: 9.4.3(eslint@9.39.4) @@ -188,6 +191,9 @@ importers: '@gocell/core': specifier: workspace:* version: link:../../packages/core + '@gocell/devboard': + specifier: workspace:* + version: link:../../packages/devboard '@gocell/request': specifier: workspace:* version: link:../../packages/request @@ -215,10 +221,10 @@ importers: version: 22.19.19 '@vitejs/plugin-vue': specifier: 'catalog:' - version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1))(vue@3.5.35(typescript@5.7.3)) + version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.35(typescript@5.7.3)) '@vitest/coverage-v8': specifier: 'catalog:' - version: 3.0.4(vitest@3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1)) + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0)) '@vue/test-utils': specifier: 'catalog:' version: 2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@5.7.3)))(vue@3.5.35(typescript@5.7.3)) @@ -230,10 +236,10 @@ importers: version: 1.100.0 vite: specifier: 'catalog:' - version: 6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) + version: 6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) + version: 3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) packages/access: dependencies: @@ -264,10 +270,10 @@ importers: version: 1.0.3(pinia@3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3))) '@vitejs/plugin-vue': specifier: 'catalog:' - version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1))(vue@3.5.35(typescript@5.7.3)) + version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.35(typescript@5.7.3)) '@vitest/coverage-v8': specifier: 'catalog:' - version: 3.0.4(vitest@3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1)) + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0)) '@vue/test-utils': specifier: 'catalog:' version: 2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@5.7.3)))(vue@3.5.35(typescript@5.7.3)) @@ -279,7 +285,7 @@ importers: version: 25.0.1 vitest: specifier: 'catalog:' - version: 3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) + version: 3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) packages/audit: dependencies: @@ -310,10 +316,10 @@ importers: version: 1.0.3(pinia@3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3))) '@vitejs/plugin-vue': specifier: 'catalog:' - version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1))(vue@3.5.35(typescript@5.7.3)) + version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.35(typescript@5.7.3)) '@vitest/coverage-v8': specifier: 'catalog:' - version: 3.0.4(vitest@3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1)) + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0)) '@vue/test-utils': specifier: 'catalog:' version: 2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@5.7.3)))(vue@3.5.35(typescript@5.7.3)) @@ -325,7 +331,7 @@ importers: version: 25.0.1 vitest: specifier: 'catalog:' - version: 3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) + version: 3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) packages/config: dependencies: @@ -356,10 +362,10 @@ importers: version: 1.0.3(pinia@3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3))) '@vitejs/plugin-vue': specifier: 'catalog:' - version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1))(vue@3.5.35(typescript@5.7.3)) + version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.35(typescript@5.7.3)) '@vitest/coverage-v8': specifier: 'catalog:' - version: 3.0.4(vitest@3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1)) + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0)) '@vue/test-utils': specifier: 'catalog:' version: 2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@5.7.3)))(vue@3.5.35(typescript@5.7.3)) @@ -371,7 +377,7 @@ importers: version: 25.0.1 vitest: specifier: 'catalog:' - version: 3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) + version: 3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) packages/contracts: {} @@ -398,10 +404,10 @@ importers: version: 1.0.3(pinia@3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3))) '@vitejs/plugin-vue': specifier: 'catalog:' - version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1))(vue@3.5.35(typescript@5.7.3)) + version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.35(typescript@5.7.3)) '@vitest/coverage-v8': specifier: 'catalog:' - version: 3.0.4(vitest@3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1)) + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0)) '@vue/test-utils': specifier: 'catalog:' version: 2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@5.7.3)))(vue@3.5.35(typescript@5.7.3)) @@ -410,9 +416,53 @@ importers: version: 25.0.1 vitest: specifier: 'catalog:' - version: 3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) + version: 3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) - packages/devboard: {} + packages/devboard: + dependencies: + '@gocell/contracts': + specifier: workspace:* + version: link:../contracts + '@gocell/core': + specifier: workspace:* + version: link:../core + '@gocell/request': + specifier: workspace:* + version: link:../request + pinia: + specifier: 'catalog:' + version: 3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3)) + vue: + specifier: 'catalog:' + version: 3.5.35(typescript@5.7.3) + vue-i18n: + specifier: 'catalog:' + version: 11.4.4(vue@3.5.35(typescript@5.7.3)) + vue-router: + specifier: 'catalog:' + version: 4.6.4(vue@3.5.35(typescript@5.7.3)) + devDependencies: + '@pinia/testing': + specifier: 'catalog:' + version: 1.0.3(pinia@3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3))) + '@vitejs/plugin-vue': + specifier: 'catalog:' + version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.35(typescript@5.7.3)) + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0)) + '@vue/test-utils': + specifier: 'catalog:' + version: 2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@5.7.3)))(vue@3.5.35(typescript@5.7.3)) + axios-mock-adapter: + specifier: 'catalog:' + version: 2.1.0(axios@1.16.1) + jsdom: + specifier: 'catalog:' + version: 25.0.1 + vitest: + specifier: 'catalog:' + version: 3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) packages/observability: {} @@ -430,16 +480,32 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: 'catalog:' - version: 3.0.4(vitest@3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1)) + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0)) axios-mock-adapter: specifier: 'catalog:' version: 2.1.0(axios@1.16.1) vitest: specifier: 'catalog:' - version: 3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) + version: 3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) packages/shared: {} + tools/cell-manifest: + dependencies: + yaml: + specifier: 'catalog:' + version: 2.9.0 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.19.19 + tsx: + specifier: 'catalog:' + version: 4.22.3 + vitest: + specifier: 'catalog:' + version: 3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) + tools/codegen: dependencies: json-schema-to-typescript: @@ -454,7 +520,7 @@ importers: version: 4.22.3 vitest: specifier: 'catalog:' - version: 3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) + version: 3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) packages: @@ -1192,6 +1258,12 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -1396,43 +1468,43 @@ packages: vite: ^5.0.0 || ^6.0.0 vue: ^3.2.25 - '@vitest/coverage-v8@3.0.4': - resolution: {integrity: sha512-f0twgRCHgbs24Dp8cLWagzcObXMcuKtAwgxjJV/nnysPAJJk1JiKu/W0gIehZLmkljhJXU/E0/dmuQzsA/4jhA==} + '@vitest/coverage-v8@3.2.6': + resolution: {integrity: sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==} peerDependencies: - '@vitest/browser': 3.0.4 - vitest: 3.0.4 + '@vitest/browser': 3.2.6 + vitest: 3.2.6 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@3.0.4': - resolution: {integrity: sha512-Nm5kJmYw6P2BxhJPkO3eKKhGYKRsnqJqf+r0yOGRKpEP+bSCBDsjXgiu1/5QFrnPMEgzfC38ZEjvCFgaNBC0Eg==} + '@vitest/expect@3.2.6': + resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} - '@vitest/mocker@3.0.4': - resolution: {integrity: sha512-gEef35vKafJlfQbnyOXZ0Gcr9IBUsMTyTLXsEQwuyYAerpHqvXhzdBnDFuHLpFqth3F7b6BaFr4qV/Cs1ULx5A==} + '@vitest/mocker@3.2.6': + resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@3.0.4': - resolution: {integrity: sha512-ts0fba+dEhK2aC9PFuZ9LTpULHpY/nd6jhAQ5IMU7Gaj7crPCTdCFfgvXxruRBLFS+MLraicCuFXxISEq8C93g==} + '@vitest/pretty-format@3.2.6': + resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} - '@vitest/runner@3.0.4': - resolution: {integrity: sha512-dKHzTQ7n9sExAcWH/0sh1elVgwc7OJ2lMOBrAm73J7AH6Pf9T12Zh3lNE1TETZaqrWFXtLlx3NVrLRb5hCK+iw==} + '@vitest/runner@3.2.6': + resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} - '@vitest/snapshot@3.0.4': - resolution: {integrity: sha512-+p5knMLwIk7lTQkM3NonZ9zBewzVp9EVkVpvNta0/PlFWpiqLaRcF4+33L1it3uRUCh0BGLOaXPPGEjNKfWb4w==} + '@vitest/snapshot@3.2.6': + resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==} - '@vitest/spy@3.0.4': - resolution: {integrity: sha512-sXIMF0oauYyUy2hN49VFTYodzEAu744MmGcPR3ZBsPM20G+1/cSW/n1U+3Yu/zHxX2bIDe1oJASOkml+osTU6Q==} + '@vitest/spy@3.2.6': + resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==} - '@vitest/utils@3.0.4': - resolution: {integrity: sha512-8BqC1ksYsHtbWH+DfpOAKrFw3jl3Uf9J7yeFh85Pz52IWuh1hBBtyfEbRNNZNjl8H8A5yMLH9/t+k7HIKzQcZQ==} + '@vitest/utils@3.2.6': + resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -1577,6 +1649,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + async-validator@4.2.5: resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} @@ -1630,8 +1705,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - chai@5.1.2: - resolution: {integrity: sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==} + chai@5.2.0: + resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} engines: {node: '>=12'} chalk@4.1.2: @@ -1800,8 +1875,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.6.0: - resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} @@ -2225,9 +2300,15 @@ packages: resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} engines: {node: '>=20'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@3.0.0: resolution: {integrity: sha512-poXEQHPMmTrYZuJgNRll2sbc3kJsSU1m/g1Q93IE6txNj3p6xOOOmdj1G/zCVGawYSPzTkSoWGg1otqbeqKJeg==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -2625,8 +2706,8 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@3.8.0: - resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} @@ -2660,6 +2741,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} @@ -2692,16 +2776,16 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} - tinypool@1.0.2: - resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} tldts-core@6.1.32: @@ -2769,8 +2853,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vite-node@3.0.4: - resolution: {integrity: sha512-7JZKEzcYV2Nx3u6rlvN8qdo3QV7Fxyt6hx+CCKz9fbWxdX5IvUOmTWEAxMrWxaiSf7CKGLJQ5rFu8prb/jBjOA==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true @@ -2814,16 +2898,16 @@ packages: yaml: optional: true - vitest@3.0.4: - resolution: {integrity: sha512-6XG8oTKy2gnJIFTHP6LD7ExFeNLxiTkK3CfMvT7IfR8IN+BYICCf0lXUQmX7i7JoxUP8QmeP4mTnWXgflu4yjw==} + vitest@3.2.6: + resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/debug': ^4.1.12 '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.0.4 - '@vitest/ui': 3.0.4 + '@vitest/browser': 3.2.6 + '@vitest/ui': 3.2.6 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -2969,6 +3053,11 @@ packages: engines: {node: '>= 14'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + snapshots: '@ampproject/remapping@2.3.0': @@ -3466,6 +3555,13 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.8': {} @@ -3641,15 +3737,16 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitejs/plugin-vue@5.2.1(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1))(vue@3.5.35(typescript@5.7.3))': + '@vitejs/plugin-vue@5.2.1(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.35(typescript@5.7.3))': dependencies: - vite: 6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) + vite: 6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) vue: 3.5.35(typescript@5.7.3) - '@vitest/coverage-v8@3.0.4(vitest@3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1))': + '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 debug: 4.4.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -3657,50 +3754,52 @@ snapshots: istanbul-reports: 3.2.0 magic-string: 0.30.21 magicast: 0.3.5 - std-env: 3.8.0 + std-env: 3.9.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) + vitest: 3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - supports-color - '@vitest/expect@3.0.4': + '@vitest/expect@3.2.6': dependencies: - '@vitest/spy': 3.0.4 - '@vitest/utils': 3.0.4 - chai: 5.1.2 + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.0.4(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1))': + '@vitest/mocker@3.2.6(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: - '@vitest/spy': 3.0.4 + '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) + vite: 6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) - '@vitest/pretty-format@3.0.4': + '@vitest/pretty-format@3.2.6': dependencies: tinyrainbow: 2.0.0 - '@vitest/runner@3.0.4': + '@vitest/runner@3.2.6': dependencies: - '@vitest/utils': 3.0.4 + '@vitest/utils': 3.2.6 pathe: 2.0.3 + strip-literal: 3.1.0 - '@vitest/snapshot@3.0.4': + '@vitest/snapshot@3.2.6': dependencies: - '@vitest/pretty-format': 3.0.4 + '@vitest/pretty-format': 3.2.6 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@3.0.4': + '@vitest/spy@3.2.6': dependencies: - tinyspy: 3.0.2 + tinyspy: 4.0.4 - '@vitest/utils@3.0.4': + '@vitest/utils@3.2.6': dependencies: - '@vitest/pretty-format': 3.0.4 + '@vitest/pretty-format': 3.2.6 loupe: 3.2.1 tinyrainbow: 2.0.0 @@ -3900,6 +3999,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-validator@4.2.5: {} asynckit@0.4.0: {} @@ -3954,7 +4059,7 @@ snapshots: callsites@3.1.0: {} - chai@5.1.2: + chai@5.2.0: dependencies: assertion-error: 2.0.1 check-error: 2.1.3 @@ -4090,7 +4195,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.6.0: {} + es-module-lexer@1.7.0: {} es-object-atoms@1.1.2: dependencies: @@ -4581,8 +4686,12 @@ snapshots: js-cookie@3.0.7: {} + js-tokens@10.0.0: {} + js-tokens@3.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -4978,7 +5087,7 @@ snapshots: stackback@0.0.2: {} - std-env@3.8.0: {} + std-env@3.9.0: {} string-argv@0.3.2: {} @@ -5012,6 +5121,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + stylis@4.4.0: {} superjson@2.2.6: @@ -5041,11 +5154,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinypool@1.0.2: {} + tinypool@1.1.1: {} tinyrainbow@2.0.0: {} - tinyspy@3.0.2: {} + tinyspy@4.0.4: {} tldts-core@6.1.32: {} @@ -5132,13 +5245,13 @@ snapshots: util-deprecate@1.0.2: {} - vite-node@3.0.4(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1): + vite-node@3.2.4(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3 - es-module-lexer: 1.6.0 + es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) + vite: 6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -5153,7 +5266,7 @@ snapshots: - tsx - yaml - vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1): + vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0): dependencies: esbuild: 0.25.0 fdir: 6.5.0(picomatch@4.0.4) @@ -5166,29 +5279,32 @@ snapshots: fsevents: 2.3.3 sass: 1.100.0 tsx: 4.22.3 - yaml: 2.6.1 - - vitest@3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1): - dependencies: - '@vitest/expect': 3.0.4 - '@vitest/mocker': 3.0.4(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1)) - '@vitest/pretty-format': 3.0.4 - '@vitest/runner': 3.0.4 - '@vitest/snapshot': 3.0.4 - '@vitest/spy': 3.0.4 - '@vitest/utils': 3.0.4 - chai: 5.1.2 + yaml: 2.9.0 + + vitest@3.2.6(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.6 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.2.0 debug: 4.4.3 expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 - std-env: 3.8.0 + picomatch: 4.0.4 + std-env: 3.9.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinypool: 1.0.2 + tinyglobby: 0.2.16 + tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) - vite-node: 3.0.4(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) + vite: 6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 @@ -5329,3 +5445,5 @@ snapshots: xmlchars@2.2.0: {} yaml@2.6.1: {} + + yaml@2.9.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6370608..e389de0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -27,6 +27,8 @@ catalog: typescript: ^5.7.3 vue-tsc: ^2.2.0 '@types/node': ^22.10.5 + # —— YAML 解析(tools/cell-manifest)—— + yaml: ^2.6.0 # —— codegen 工具链(tools/codegen)—— # 注:codegen 工具链升版后须本地重跑 `pnpm codegen && git add packages/contracts/src/`, # 否则 formatter 输出微变会触发 CI codegen-diff 误红。 diff --git a/tools/cell-manifest/README.md b/tools/cell-manifest/README.md new file mode 100644 index 0000000..7956241 --- /dev/null +++ b/tools/cell-manifest/README.md @@ -0,0 +1,33 @@ +# @gocell/cell-manifest + +> Cell manifest 单向派生器:后端 `gocell/cells/*/cell.yaml` + `slices/*/slice.yaml` → `packages/devboard/src/manifest/cells.generated.ts`。 + +是 AI-robust「Hard」约束的执行体(`ai-robust.md` §载体决策原则 第 1 条):单源 YAML 派生 + CI `git diff --exit-code` 守门(`.github/workflows/cell-manifest-diff.yml`),业务包手改生成物在 CI 不可表达。生成文件带 `: CellManifest` 类型注解(来自 devboard 的规范 `./types`),任何形状漂移在 `@gocell/devboard typecheck` 失败。 + +## 运行 + +```bash +pnpm cell-manifest # = pnpm -F @gocell/cell-manifest generate +``` + +- **源路径**:默认 `/../gocell/cells`(gocell-web 与后端 gocell 同级 checkout;CI 亦如此布局)。可经环境变量 `GOCELL_CELLS_DIR` 覆盖(如 git worktree 本地开发:`GOCELL_CELLS_DIR=../gocell/cells pnpm cell-manifest`)。 +- **产物**:`packages/devboard/src/manifest/cells.generated.ts`,带 `/* eslint-disable */` + DO-NOT-EDIT banner(已加入 `eslint.config.js` ignores + `.prettierignore`)。 + +## 派生规则 + +- `cell.yaml` → `CellEntry`(id / type / consistencyLevel / durabilityMode / owner / goStructName / schema.primary / requires / l0Dependencies / verify.smoke)。`name` = goStructName;`domain` = 去尾 `core` 后 title-case。 +- `slice.yaml` 的 `contractUsages[].role`:`serve`/`publish` → cell **produces**;`call`/`subscribe` → **consumes**(按 contract 去重 + 排序)。 +- 跨 cell 依赖:某 cell consume 的契约由另一 cell serve/publish → `dependsOnCells`;反向得 `requiredByCells`。 +- **未知 role 静默忽略**(不抛错)——构建工具读外部 YAML 须对后端契约演进鲁棒;新增 role 在前端补支持前不分类,而非令派生崩溃。反向自检见 `src/derive.spec.ts`。 + +## 确定性来源(CI 不误红的前提) + +- 排序遍历(目录条目字典序)+ produces/consumes/dependsOnCells/cells 均排序。 +- 输出 `JSON.stringify(manifest, null, 2)`(对象已有序);无时间戳字段。 +- 类型由本工具自包含的 `src/types.ts` 提供(规范源为 `packages/devboard/src/manifest/types.ts`,漂移由 devboard typecheck 守门,见上)。 + +## 测试 + +```bash +pnpm -F @gocell/cell-manifest test +``` diff --git a/tools/cell-manifest/package.json b/tools/cell-manifest/package.json new file mode 100644 index 0000000..a1f652a --- /dev/null +++ b/tools/cell-manifest/package.json @@ -0,0 +1,19 @@ +{ + "name": "@gocell/cell-manifest", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "generate": "tsx src/index.ts", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "yaml": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "tsx": "catalog:", + "vitest": "catalog:" + } +} diff --git a/tools/cell-manifest/src/derive.spec.ts b/tools/cell-manifest/src/derive.spec.ts new file mode 100644 index 0000000..a4279ad --- /dev/null +++ b/tools/cell-manifest/src/derive.spec.ts @@ -0,0 +1,669 @@ +/** + * derive.spec.ts — FIXTURE-BASED unit tests for pure derivation logic. + * + * BLIND SPOTS (ai-robust §盲区自检 — forms NOT covered by this spec): + * 1. Dynamic YAML runtime parse errors (index.ts handles, not derive.ts) + * 2. File-system traversal order (index.ts; tested by determinism case here) + * 3. slice.yaml contractUsages with extra fields (handler, group, field) — we + * only use `contract` + `role`; extra fields are silently ignored (correct) + * 4. Cells with identical contract names across different slices — dedup + * keeps first occurrence (by slice order); tested explicitly below + * + * Reverse blind-spot checks (MUST assert 0 false matches): + * - Unknown role → NOT in produces/consumes + * - Waiver contract id → NOT in produces/consumes + * - Empty contractUsages → produces/consumes are [] not undefined + * - Cell with no slices → slices: [] + * - Determinism: buildManifest twice → deep-equal + */ +import { describe, it, expect } from 'vitest' +import { + humanizeDomain, + parseCell, + parseSlice, + deriveProducesConsumes, + buildManifest, + renderManifestModule, +} from './derive' +import type { CellManifest } from './types' + +// ─── humanizeDomain ────────────────────────────────────────────────────────── + +describe('humanizeDomain', () => { + it("strips trailing 'core' and title-cases remainder", () => { + expect(humanizeDomain('accesscore')).toBe('Access') + expect(humanizeDomain('auditcore')).toBe('Audit') + expect(humanizeDomain('configcore')).toBe('Config') + }) + + it('title-cases whole id when no core suffix', () => { + expect(humanizeDomain('foo')).toBe('Foo') + expect(humanizeDomain('devboard')).toBe('Devboard') + }) +}) + +// ─── parseSlice ────────────────────────────────────────────────────────────── + +describe('parseSlice', () => { + it('parses a minimal slice with contractUsages', () => { + const raw = { + id: 'sessionlogin', + belongsToCell: 'accesscore', + consistencyLevel: 'L2', + lifecycle: 'asset', + contractUsages: [ + { contract: 'http.auth.login.v1', role: 'serve' }, + { contract: 'event.session.created.v1', role: 'publish' }, + { contract: 'http.config.get.v1', role: 'call' }, + ], + verify: { + unit: ['unit.sessionlogin.service'], + contract: ['contract.http.auth.login.v1.serve'], + waivers: [ + { + contract: 'http.config.get.v1', + owner: 'platform-team', + reason: 'already covered', + expiresAt: '2026-09-01', + }, + ], + }, + allowedFiles: ['cells/accesscore/slices/sessionlogin/**'], + } + const slice = parseSlice(raw) + expect(slice.id).toBe('sessionlogin') + expect(slice.belongsToCell).toBe('accesscore') + expect(slice.consistencyLevel).toBe('L2') + expect(slice.lifecycle).toBe('asset') + expect(slice.contractUsages).toHaveLength(3) + expect(slice.contractUsages[0]).toEqual({ contract: 'http.auth.login.v1', role: 'serve' }) + expect(slice.unitTests).toEqual(['unit.sessionlogin.service']) + expect(slice.contractTests).toEqual(['contract.http.auth.login.v1.serve']) + expect(slice.waivers).toHaveLength(1) + expect(slice.waivers[0]?.expiresAt).toBe('2026-09-01') + }) + + it('handles empty contractUsages array', () => { + const raw = { + id: 'authorizationdecide', + belongsToCell: 'accesscore', + consistencyLevel: 'L0', + lifecycle: 'asset', + contractUsages: [], + verify: { unit: [], contract: [], waivers: [] }, + } + const slice = parseSlice(raw) + expect(slice.contractUsages).toEqual([]) + expect(slice.waivers).toEqual([]) + }) + + it('handles missing verify fields gracefully', () => { + const raw = { + id: 'simple', + belongsToCell: 'somecore', + consistencyLevel: 'L1', + lifecycle: 'asset', + contractUsages: [], + verify: {}, + } + const slice = parseSlice(raw) + expect(slice.unitTests).toEqual([]) + expect(slice.contractTests).toEqual([]) + expect(slice.waivers).toEqual([]) + }) +}) + +// ─── parseCell ─────────────────────────────────────────────────────────────── + +describe('parseCell', () => { + it('parses a full cell object', () => { + const raw = { + id: 'accesscore', + type: 'core', + consistencyLevel: 'L3', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + schema: { primary: 'cell_access_core' }, + verify: { smoke: ['smoke.accesscore.startup'] }, + goStructName: 'AccessCore', + l0Dependencies: [], + requires: ['postgres', 'redis'], + } + const cell = parseCell(raw) + expect(cell.id).toBe('accesscore') + expect(cell.name).toBe('AccessCore') + expect(cell.domain).toBe('Access') + expect(cell.type).toBe('core') + expect(cell.consistencyLevel).toBe('L3') + expect(cell.lifecycle).toBe('asset') + expect(cell.durabilityMode).toBe('durable') + expect(cell.owner).toEqual({ team: 'platform', role: 'cell-owner' }) + expect(cell.goStructName).toBe('AccessCore') + expect(cell.schemaPrimary).toBe('cell_access_core') + expect(cell.requires).toEqual(['postgres', 'redis']) + expect(cell.l0Dependencies).toEqual([]) + expect(cell.smokeTests).toEqual(['smoke.accesscore.startup']) + // slices/produces/consumes/dependsOnCells/requiredByCells set later + expect(cell.slices).toEqual([]) + expect(cell.produces).toEqual([]) + expect(cell.consumes).toEqual([]) + expect(cell.dependsOnCells).toEqual([]) + expect(cell.requiredByCells).toEqual([]) + }) + + it('passes through durabilityMode durable and demo', () => { + const durable = parseCell({ + id: 'a', + type: 'core', + consistencyLevel: 'L0', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 't', role: 'r' }, + schema: { primary: null }, + verify: { smoke: [] }, + goStructName: 'A', + l0Dependencies: [], + requires: [], + }) + expect(durable.durabilityMode).toBe('durable') + + const demo = parseCell({ + id: 'b', + type: 'core', + consistencyLevel: 'L0', + lifecycle: 'asset', + durabilityMode: 'demo', + owner: { team: 't', role: 'r' }, + schema: { primary: null }, + verify: { smoke: [] }, + goStructName: 'B', + l0Dependencies: [], + requires: [], + }) + expect(demo.durabilityMode).toBe('demo') + }) + + it('handles null schemaPrimary', () => { + const cell = parseCell({ + id: 'x', + type: 'core', + consistencyLevel: 'L0', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 't', role: 'r' }, + schema: { primary: null }, + verify: { smoke: [] }, + goStructName: 'X', + l0Dependencies: [], + requires: [], + }) + expect(cell.schemaPrimary).toBeNull() + }) +}) + +// ─── deriveProducesConsumes ─────────────────────────────────────────────────── + +describe('deriveProducesConsumes', () => { + it('serve role → produces', () => { + const slices = [ + parseSlice({ + id: 's1', + belongsToCell: 'c', + consistencyLevel: 'L0', + lifecycle: 'asset', + contractUsages: [{ contract: 'http.foo.v1', role: 'serve' }], + verify: { unit: [], contract: [], waivers: [] }, + }), + ] + const { produces, consumes } = deriveProducesConsumes(slices) + expect(produces).toEqual([{ contract: 'http.foo.v1', role: 'serve' }]) + expect(consumes).toEqual([]) + }) + + it('publish role → produces', () => { + const slices = [ + parseSlice({ + id: 's1', + belongsToCell: 'c', + consistencyLevel: 'L0', + lifecycle: 'asset', + contractUsages: [{ contract: 'event.bar.v1', role: 'publish' }], + verify: { unit: [], contract: [], waivers: [] }, + }), + ] + const { produces, consumes } = deriveProducesConsumes(slices) + expect(produces).toEqual([{ contract: 'event.bar.v1', role: 'publish' }]) + expect(consumes).toEqual([]) + }) + + it('call role → consumes', () => { + const slices = [ + parseSlice({ + id: 's1', + belongsToCell: 'c', + consistencyLevel: 'L0', + lifecycle: 'asset', + contractUsages: [{ contract: 'http.other.v1', role: 'call' }], + verify: { unit: [], contract: [], waivers: [] }, + }), + ] + const { produces, consumes } = deriveProducesConsumes(slices) + expect(produces).toEqual([]) + expect(consumes).toEqual([{ contract: 'http.other.v1', role: 'call' }]) + }) + + it('subscribe role → consumes', () => { + const slices = [ + parseSlice({ + id: 's1', + belongsToCell: 'c', + consistencyLevel: 'L0', + lifecycle: 'asset', + contractUsages: [{ contract: 'event.baz.v1', role: 'subscribe' }], + verify: { unit: [], contract: [], waivers: [] }, + }), + ] + const { produces, consumes } = deriveProducesConsumes(slices) + expect(produces).toEqual([]) + expect(consumes).toEqual([{ contract: 'event.baz.v1', role: 'subscribe' }]) + }) + + it('slice with both serve+call → appears in both produces and consumes', () => { + const slices = [ + parseSlice({ + id: 's1', + belongsToCell: 'c', + consistencyLevel: 'L2', + lifecycle: 'asset', + contractUsages: [ + { contract: 'http.auth.login.v1', role: 'serve' }, + { contract: 'http.config.get.v1', role: 'call' }, + ], + verify: { unit: [], contract: [], waivers: [] }, + }), + ] + const { produces, consumes } = deriveProducesConsumes(slices) + expect(produces.map((p) => p.contract)).toContain('http.auth.login.v1') + expect(consumes.map((c) => c.contract)).toContain('http.config.get.v1') + }) + + it('deduplicates by contract, first wins, sorted by contract', () => { + const slices = [ + parseSlice({ + id: 's1', + belongsToCell: 'c', + consistencyLevel: 'L0', + lifecycle: 'asset', + contractUsages: [ + { contract: 'http.z.v1', role: 'serve' }, + { contract: 'http.a.v1', role: 'serve' }, + { contract: 'http.z.v1', role: 'serve' }, // duplicate + ], + verify: { unit: [], contract: [], waivers: [] }, + }), + ] + const { produces } = deriveProducesConsumes(slices) + expect(produces.map((p) => p.contract)).toEqual(['http.a.v1', 'http.z.v1']) + expect(produces).toHaveLength(2) + }) + + it('dedup across multiple slices: first slice wins', () => { + const slices = [ + parseSlice({ + id: 's1', + belongsToCell: 'c', + consistencyLevel: 'L0', + lifecycle: 'asset', + contractUsages: [{ contract: 'event.x.v1', role: 'publish' }], + verify: { unit: [], contract: [], waivers: [] }, + }), + parseSlice({ + id: 's2', + belongsToCell: 'c', + consistencyLevel: 'L0', + lifecycle: 'asset', + contractUsages: [{ contract: 'event.x.v1', role: 'publish' }], // duplicate across slices + verify: { unit: [], contract: [], waivers: [] }, + }), + ] + const { produces } = deriveProducesConsumes(slices) + expect(produces).toHaveLength(1) + expect(produces[0]?.contract).toBe('event.x.v1') + }) + + // ── REVERSE BLIND-SPOT CHECKS (0 false-positive assertions) ───────────────── + + it('[blind-spot] unknown role e.g. "observe" → NOT in produces/consumes', () => { + const slices = [ + parseSlice({ + id: 's1', + belongsToCell: 'c', + consistencyLevel: 'L0', + lifecycle: 'asset', + contractUsages: [{ contract: 'event.observe.v1', role: 'observe' as 'serve' }], + verify: { unit: [], contract: [], waivers: [] }, + }), + ] + const { produces, consumes } = deriveProducesConsumes(slices) + expect(produces).toEqual([]) + expect(consumes).toEqual([]) + }) + + it('[blind-spot] waiver contract id → NOT in produces/consumes', () => { + const slices = [ + parseSlice({ + id: 's1', + belongsToCell: 'c', + consistencyLevel: 'L0', + lifecycle: 'asset', + contractUsages: [{ contract: 'http.known.v1', role: 'serve' }], + verify: { + unit: [], + contract: [], + waivers: [ + { + contract: 'http.waived.v1', + owner: 'team', + reason: 'reason', + expiresAt: '2027-01-01', + }, + ], + }, + }), + ] + const { produces, consumes } = deriveProducesConsumes(slices) + expect(produces.map((p) => p.contract)).not.toContain('http.waived.v1') + expect(consumes.map((c) => c.contract)).not.toContain('http.waived.v1') + }) + + it('[blind-spot] empty contractUsages → produces/consumes are [] not undefined', () => { + const slices = [ + parseSlice({ + id: 's1', + belongsToCell: 'c', + consistencyLevel: 'L0', + lifecycle: 'asset', + contractUsages: [], + verify: { unit: [], contract: [], waivers: [] }, + }), + ] + const { produces, consumes } = deriveProducesConsumes(slices) + expect(produces).toEqual([]) + expect(consumes).toEqual([]) + expect(Array.isArray(produces)).toBe(true) + expect(Array.isArray(consumes)).toBe(true) + }) +}) + +// ─── buildManifest ──────────────────────────────────────────────────────────── + +describe('buildManifest', () => { + it('[blind-spot] cell with no slices → slices: []', () => { + const manifest = buildManifest([ + { + cell: { + id: 'emptycore', + type: 'core', + consistencyLevel: 'L0' as const, + lifecycle: 'asset', + durabilityMode: 'durable' as const, + owner: { team: 'platform', role: 'cell-owner' }, + schema: { primary: null }, + verify: { smoke: [] }, + goStructName: 'Empty', + l0Dependencies: [], + requires: [], + }, + slices: [], + }, + ]) + expect(manifest.cells[0]?.slices).toEqual([]) + expect(manifest.cells[0]?.produces).toEqual([]) + expect(manifest.cells[0]?.consumes).toEqual([]) + }) + + it('cross-cell dependency: cell B consumes contract X served by cell A → B.dependsOnCells includes A', () => { + const manifest = buildManifest([ + { + cell: { + id: 'acore', + type: 'core', + consistencyLevel: 'L0' as const, + lifecycle: 'asset', + durabilityMode: 'durable' as const, + owner: { team: 't', role: 'r' }, + schema: { primary: null }, + verify: { smoke: [] }, + goStructName: 'A', + l0Dependencies: [], + requires: [], + }, + slices: [ + { + id: 'aslice', + belongsToCell: 'acore', + consistencyLevel: 'L0' as const, + lifecycle: 'asset', + contractUsages: [{ contract: 'http.contract.x.v1', role: 'serve' as const }], + verify: { unit: [], contract: [], waivers: [] }, + }, + ], + }, + { + cell: { + id: 'bcore', + type: 'core', + consistencyLevel: 'L0' as const, + lifecycle: 'asset', + durabilityMode: 'durable' as const, + owner: { team: 't', role: 'r' }, + schema: { primary: null }, + verify: { smoke: [] }, + goStructName: 'B', + l0Dependencies: [], + requires: [], + }, + slices: [ + { + id: 'bslice', + belongsToCell: 'bcore', + consistencyLevel: 'L0' as const, + lifecycle: 'asset', + contractUsages: [{ contract: 'http.contract.x.v1', role: 'call' as const }], + verify: { unit: [], contract: [], waivers: [] }, + }, + ], + }, + ]) + const b = manifest.cells.find((c) => c.id === 'bcore') + const a = manifest.cells.find((c) => c.id === 'acore') + expect(b?.dependsOnCells).toContain('acore') + expect(a?.requiredByCells).toContain('bcore') + }) + + it('self-served contract does NOT create a self-dep', () => { + const manifest = buildManifest([ + { + cell: { + id: 'selfcore', + type: 'core', + consistencyLevel: 'L0' as const, + lifecycle: 'asset', + durabilityMode: 'durable' as const, + owner: { team: 't', role: 'r' }, + schema: { primary: null }, + verify: { smoke: [] }, + goStructName: 'Self', + l0Dependencies: [], + requires: [], + }, + slices: [ + { + id: 'slice1', + belongsToCell: 'selfcore', + consistencyLevel: 'L0' as const, + lifecycle: 'asset', + contractUsages: [{ contract: 'http.self.v1', role: 'serve' as const }], + verify: { unit: [], contract: [], waivers: [] }, + }, + { + id: 'slice2', + belongsToCell: 'selfcore', + consistencyLevel: 'L0' as const, + lifecycle: 'asset', + contractUsages: [{ contract: 'http.self.v1', role: 'call' as const }], + verify: { unit: [], contract: [], waivers: [] }, + }, + ], + }, + ]) + const self = manifest.cells.find((c) => c.id === 'selfcore') + expect(self?.dependsOnCells).toEqual([]) + }) + + it('cells sorted by id in manifest.cells', () => { + const manifest = buildManifest([ + { + cell: { + id: 'zcore', + type: 'core', + consistencyLevel: 'L0' as const, + lifecycle: 'asset', + durabilityMode: 'durable' as const, + owner: { team: 't', role: 'r' }, + schema: { primary: null }, + verify: { smoke: [] }, + goStructName: 'Z', + l0Dependencies: [], + requires: [], + }, + slices: [], + }, + { + cell: { + id: 'acore', + type: 'core', + consistencyLevel: 'L0' as const, + lifecycle: 'asset', + durabilityMode: 'durable' as const, + owner: { team: 't', role: 'r' }, + schema: { primary: null }, + verify: { smoke: [] }, + goStructName: 'A', + l0Dependencies: [], + requires: [], + }, + slices: [], + }, + ]) + expect(manifest.cells[0]?.id).toBe('acore') + expect(manifest.cells[1]?.id).toBe('zcore') + }) + + it('generatedFrom is the static note (deterministic, no timestamp)', () => { + const manifest = buildManifest([]) + expect(manifest.generatedFrom).toBe('gocell/cells/**/{cell.yaml,slices/*/slice.yaml}') + }) + + it('[blind-spot] determinism: buildManifest twice on same input → deep-equal', () => { + const input: { + cell: Parameters[0][number]['cell'] + slices: Parameters[0][number]['slices'] + }[] = [ + { + cell: { + id: 'accesscore', + type: 'core', + consistencyLevel: 'L3' as const, + lifecycle: 'asset', + durabilityMode: 'durable' as const, + owner: { team: 'platform', role: 'cell-owner' }, + schema: { primary: 'cell_access_core' }, + verify: { smoke: ['smoke.accesscore.startup'] }, + goStructName: 'AccessCore', + l0Dependencies: [], + requires: ['postgres'], + }, + slices: [ + { + id: 'sessionlogin', + belongsToCell: 'accesscore', + consistencyLevel: 'L2' as const, + lifecycle: 'asset', + contractUsages: [ + { contract: 'http.auth.login.v1', role: 'serve' as const }, + { contract: 'http.config.get.v1', role: 'call' as const }, + ], + verify: { unit: [], contract: [], waivers: [] }, + }, + ], + }, + ] + const m1 = buildManifest(input) + const m2 = buildManifest(input) + expect(JSON.stringify(m1)).toBe(JSON.stringify(m2)) + }) + + it('waivers passthrough: expiresAt preserved as-is (not coerced)', () => { + const manifest = buildManifest([ + { + cell: { + id: 'wcore', + type: 'core', + consistencyLevel: 'L0' as const, + lifecycle: 'asset', + durabilityMode: 'durable' as const, + owner: { team: 't', role: 'r' }, + schema: { primary: null }, + verify: { smoke: [] }, + goStructName: 'W', + l0Dependencies: [], + requires: [], + }, + slices: [ + { + id: 'wslice', + belongsToCell: 'wcore', + consistencyLevel: 'L0' as const, + lifecycle: 'asset', + contractUsages: [], + verify: { + unit: [], + contract: [], + waivers: [ + { + contract: 'http.x.v1', + owner: 'team', + reason: 'reason', + expiresAt: '2026-09-01', + }, + ], + }, + }, + ], + }, + ]) + const slice = manifest.cells[0]?.slices[0] + expect(slice?.waivers[0]?.expiresAt).toBe('2026-09-01') + // expiresAt should be string, not Date + expect(typeof slice?.waivers[0]?.expiresAt).toBe('string') + }) +}) + +// ─── renderManifestModule ───────────────────────────────────────────────────── + +describe('renderManifestModule', () => { + it('produces a TS module string with CELL_MANIFEST export', () => { + const manifest: CellManifest = { + cells: [], + generatedFrom: 'gocell/cells/**/{cell.yaml,slices/*/slice.yaml}', + } + const output = renderManifestModule(manifest) + expect(output).toContain('/* eslint-disable */') + expect(output).toContain('AUTO-GENERATED') + expect(output).toContain('DO NOT EDIT BY HAND') + expect(output).toContain("import type { CellManifest } from './types'") + expect(output).toContain('export const CELL_MANIFEST: CellManifest =') + }) +}) diff --git a/tools/cell-manifest/src/derive.ts b/tools/cell-manifest/src/derive.ts new file mode 100644 index 0000000..d13b4bd --- /dev/null +++ b/tools/cell-manifest/src/derive.ts @@ -0,0 +1,337 @@ +/** + * @gocell/cell-manifest — 纯派生逻辑(与文件 IO 解耦,便于单测)。 + * + * Derives CellManifest from parsed cell.yaml + slices/[*]/slice.yaml objects. + * No fs / YAML IO dependencies — those belong to index.ts (IO layer). + * + * BLIND SPOTS (ai-robust §盲区自检 — 本模块不覆盖的形态): + * 1. 未知 contractUsage role(如 'observe')— silently ignored(produces/consumes 不含) + * 反向自检:derive.spec.ts '[blind-spot] unknown role' + * 2. Waiver contract 字段 — only in verify.waivers, never in contractUsages → + * waivers passthrough, never enters produces/consumes + * 反向自检:derive.spec.ts '[blind-spot] waiver contract id' + * 3. Empty contractUsages → [](not undefined) + * 反向自检:derive.spec.ts '[blind-spot] empty contractUsages' + * 4. Cell with no slices → slices: [] + * 反向自检:derive.spec.ts '[blind-spot] cell with no slices' + * 5. schema.primary absent or null → schemaPrimary: null + * 6. verify.smoke absent → smokeTests: [] + */ + +import type { + CellEntry, + CellManifest, + CellSlice, + CellWaiver, + ConsistencyLevel, + ContractRole, + ContractUsage, + DurabilityMode, +} from './types' + +// ─── Type guards ───────────────────────────────────────────────────────────── + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +function isStringArray(v: unknown): v is string[] { + return Array.isArray(v) && v.every((x) => typeof x === 'string') +} + +function isConsistencyLevel(v: unknown): v is ConsistencyLevel { + return v === 'L0' || v === 'L1' || v === 'L2' || v === 'L3' +} + +function isDurabilityMode(v: unknown): v is DurabilityMode { + return v === 'durable' || v === 'demo' +} + +function isContractRole(v: unknown): v is ContractRole { + return v === 'serve' || v === 'call' || v === 'publish' || v === 'subscribe' +} + +// ─── humanizeDomain ────────────────────────────────────────────────────────── + +/** + * Derive a human-readable domain label from a cell id. + * 'accesscore' → strip trailing 'core' → 'access' → title-case → 'Access' + * 'foo' (no core suffix) → title-case whole → 'Foo' + */ +export function humanizeDomain(id: string): string { + const stripped = id.endsWith('core') ? id.slice(0, -4) : id + const base = stripped.length > 0 ? stripped : id + return base.charAt(0).toUpperCase() + base.slice(1) +} + +// ─── parseSlice ────────────────────────────────────────────────────────────── + +/** + * Parse a raw (unknown-typed) slice YAML object into a typed CellSlice. + * Called by index.ts after YAML.parse; also called by specs with inline fixtures. + */ +export function parseSlice(raw: unknown): CellSlice { + if (!isRecord(raw)) throw new Error(`slice must be an object, got: ${typeof raw}`) + + const id = typeof raw['id'] === 'string' ? raw['id'] : '' + const belongsToCell = typeof raw['belongsToCell'] === 'string' ? raw['belongsToCell'] : '' + const consistencyLevel = isConsistencyLevel(raw['consistencyLevel']) + ? raw['consistencyLevel'] + : 'L0' + const lifecycle = typeof raw['lifecycle'] === 'string' ? raw['lifecycle'] : '' + + // Parse contractUsages — only keep known roles + const contractUsages: ContractUsage[] = [] + const rawUsages = raw['contractUsages'] + if (Array.isArray(rawUsages)) { + for (const usage of rawUsages) { + if (!isRecord(usage)) continue + const contract = typeof usage['contract'] === 'string' ? usage['contract'] : null + const role = usage['role'] + if (contract !== null && isContractRole(role)) { + contractUsages.push({ contract, role }) + } + // Unknown roles are silently ignored (blind-spot #1) + } + } + + // Parse verify section + const verify = isRecord(raw['verify']) ? raw['verify'] : {} + const unitTests = isStringArray(verify['unit']) ? verify['unit'] : [] + const contractTests = isStringArray(verify['contract']) ? verify['contract'] : [] + + const waivers: CellWaiver[] = [] + const rawWaivers = verify['waivers'] + if (Array.isArray(rawWaivers)) { + for (const w of rawWaivers) { + if (!isRecord(w)) continue + if ( + typeof w['contract'] === 'string' && + typeof w['owner'] === 'string' && + typeof w['reason'] === 'string' && + typeof w['expiresAt'] === 'string' + ) { + waivers.push({ + contract: w['contract'], + owner: w['owner'], + reason: w['reason'], + expiresAt: w['expiresAt'], // preserved as-is (string, NOT coerced to Date) + }) + } + } + } + + return { + id, + belongsToCell, + consistencyLevel, + lifecycle, + contractUsages, + unitTests, + contractTests, + waivers, + } +} + +// ─── parseCell ─────────────────────────────────────────────────────────────── + +/** + * Parse a raw cell.yaml object into a partial CellEntry. + * slices, produces, consumes, dependsOnCells, requiredByCells are set later + * in buildManifest after all cells and slices are collected. + */ +export function parseCell(raw: unknown): Omit< + CellEntry, + 'slices' | 'produces' | 'consumes' | 'dependsOnCells' | 'requiredByCells' +> & { + slices: readonly CellSlice[] + produces: readonly ContractUsage[] + consumes: readonly ContractUsage[] + dependsOnCells: readonly string[] + requiredByCells: readonly string[] +} { + if (!isRecord(raw)) throw new Error(`cell must be an object, got: ${typeof raw}`) + + const id = typeof raw['id'] === 'string' ? raw['id'] : '' + const type = typeof raw['type'] === 'string' ? raw['type'] : '' + const consistencyLevel = isConsistencyLevel(raw['consistencyLevel']) + ? raw['consistencyLevel'] + : 'L0' + const lifecycle = typeof raw['lifecycle'] === 'string' ? raw['lifecycle'] : '' + const durabilityMode = isDurabilityMode(raw['durabilityMode']) ? raw['durabilityMode'] : 'durable' + const goStructName = typeof raw['goStructName'] === 'string' ? raw['goStructName'] : id + + const owner = isRecord(raw['owner']) + ? { + team: typeof raw['owner']['team'] === 'string' ? raw['owner']['team'] : '', + role: typeof raw['owner']['role'] === 'string' ? raw['owner']['role'] : '', + } + : { team: '', role: '' } + + const schema = isRecord(raw['schema']) ? raw['schema'] : {} + const schemaPrimary = typeof schema['primary'] === 'string' ? schema['primary'] : null + + const verify = isRecord(raw['verify']) ? raw['verify'] : {} + const smokeTests = isStringArray(verify['smoke']) ? verify['smoke'] : [] + + const requires = isStringArray(raw['requires']) ? raw['requires'] : [] + const l0Dependencies = isStringArray(raw['l0Dependencies']) ? raw['l0Dependencies'] : [] + + return { + id, + name: goStructName, + domain: humanizeDomain(id), + type, + consistencyLevel, + lifecycle, + durabilityMode, + owner, + goStructName, + schemaPrimary, + requires, + l0Dependencies, + smokeTests, + // These are set in buildManifest after cross-cell analysis + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], + } +} + +// ─── deriveProducesConsumes ─────────────────────────────────────────────────── + +/** + * Derive produces/consumes for a cell from its slices. + * - produces: role serve|publish, deduped by contract (first wins), sorted by contract + * - consumes: role call|subscribe, deduped by contract (first wins), sorted by contract + */ +export function deriveProducesConsumes(slices: readonly CellSlice[]): { + produces: ContractUsage[] + consumes: ContractUsage[] +} { + const producedSet = new Map() + const consumedSet = new Map() + + for (const slice of slices) { + for (const usage of slice.contractUsages) { + if (usage.role === 'serve' || usage.role === 'publish') { + if (!producedSet.has(usage.contract)) { + producedSet.set(usage.contract, { contract: usage.contract, role: usage.role }) + } + } else if (usage.role === 'call' || usage.role === 'subscribe') { + if (!consumedSet.has(usage.contract)) { + consumedSet.set(usage.contract, { contract: usage.contract, role: usage.role }) + } + } + // Unknown roles silently ignored (blind-spot #1) + } + } + + const produces = [...producedSet.values()].sort((a, b) => + a.contract < b.contract ? -1 : a.contract > b.contract ? 1 : 0, + ) + const consumes = [...consumedSet.values()].sort((a, b) => + a.contract < b.contract ? -1 : a.contract > b.contract ? 1 : 0, + ) + + return { produces, consumes } +} + +// ─── buildManifest ──────────────────────────────────────────────────────────── + +type RawCellWithSlices = { + cell: unknown + slices: unknown[] +} + +/** + * Build a complete CellManifest from raw cell+slices pairs. + * Steps: + * 1. Parse all cells and slices + * 2. Compute produces/consumes per cell + * 3. Build contract → producingCellId map + * 4. Compute cross-cell dependsOnCells / requiredByCells + * 5. Sort cells by id (byId lookup is derived at runtime in useCellsStore) + */ +export function buildManifest(rawCells: RawCellWithSlices[]): CellManifest { + // Step 1: parse cells + slices + const entries: CellEntry[] = rawCells.map(({ cell, slices }) => { + const parsed = parseCell(cell) + const parsedSlices = slices.map(parseSlice) + const { produces, consumes } = deriveProducesConsumes(parsedSlices) + return { + ...parsed, + slices: parsedSlices, + produces, + consumes, + dependsOnCells: [], // filled below + requiredByCells: [], // filled below + } + }) + + // Step 2: build contract → producingCellId map (first producer wins) + const contractProducer = new Map() + for (const entry of entries) { + for (const p of entry.produces) { + if (!contractProducer.has(p.contract)) { + contractProducer.set(p.contract, entry.id) + } + } + } + + // Step 3: compute cross-cell deps + const dependsOnMap = new Map>() + const requiredByMap = new Map>() + for (const entry of entries) { + dependsOnMap.set(entry.id, new Set()) + requiredByMap.set(entry.id, new Set()) + } + + for (const entry of entries) { + for (const c of entry.consumes) { + const producer = contractProducer.get(c.contract) + if (producer !== undefined && producer !== entry.id) { + // entry depends on producer + dependsOnMap.get(entry.id)?.add(producer) + // producer is required by entry + requiredByMap.get(producer)?.add(entry.id) + } + // self-served contracts are excluded (blind-spot #4 self-dep guard) + } + } + + // Step 4: compose final entries with sorted deps, sorted by id + const finalEntries: CellEntry[] = entries + .map((entry) => ({ + ...entry, + dependsOnCells: [...(dependsOnMap.get(entry.id) ?? [])].sort(), + requiredByCells: [...(requiredByMap.get(entry.id) ?? [])].sort(), + })) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + + return { + cells: finalEntries, + generatedFrom: 'gocell/cells/**/{cell.yaml,slices/*/slice.yaml}', + } +} + +// ─── renderManifestModule ───────────────────────────────────────────────────── + +/** + * Render the generated TypeScript module text for cells.generated.ts. + * Uses JSON.stringify for deterministic output (manifest object is already sorted). + */ +export function renderManifestModule(m: CellManifest): string { + return [ + '/* eslint-disable */', + '// AUTO-GENERATED by tools/cell-manifest — DO NOT EDIT BY HAND.', + '// Source: gocell/cells/**', + '// Regenerate: pnpm cell-manifest (CI guards via git diff --exit-code)', + "import type { CellManifest } from './types'", + '', + `export const CELL_MANIFEST: CellManifest = ${JSON.stringify(m, null, 2)}`, + '', + ].join('\n') +} diff --git a/tools/cell-manifest/src/index.ts b/tools/cell-manifest/src/index.ts new file mode 100644 index 0000000..0755842 --- /dev/null +++ b/tools/cell-manifest/src/index.ts @@ -0,0 +1,93 @@ +/** + * @gocell/cell-manifest — IO orchestration layer. + * + * Reads cell.yaml + slices/[x]/slice.yaml from GOCELL_CELLS_DIR (defaults to + * ../gocell/cells relative to repo root), derives CellManifest via pure + * derive.ts functions, and writes the generated TS module to + * packages/devboard/src/manifest/cells.generated.ts. + * + * AI-robust Hard guard: CI runs `pnpm cell-manifest` then asserts + * `git status --porcelain packages/devboard/src/manifest/` is empty. + * Determinism guaranteed by sorted directory traversal + stable JSON.stringify. + */ +import { readdirSync, existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse } from 'yaml' +import { buildManifest, renderManifestModule } from './derive' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = resolve(SCRIPT_DIR, '../../..') +const CELLS_DIR = resolve(process.env['GOCELL_CELLS_DIR'] ?? join(REPO_ROOT, '../gocell/cells')) +const OUT = join(REPO_ROOT, 'packages/devboard/src/manifest/cells.generated.ts') + +function main(): void { + if (!existsSync(CELLS_DIR)) { + throw new Error( + `Cell source directory not found: ${CELLS_DIR}\n` + + `Check out the backend repo ghbvf/gocell as a sibling of this repo, ` + + `or set GOCELL_CELLS_DIR to point to the cells directory.`, + ) + } + + // Safety guard: assert OUT path ends with the expected suffix before writing. + const expectedSuffix = join('packages', 'devboard', 'src', 'manifest', 'cells.generated.ts') + if (!OUT.endsWith(expectedSuffix)) { + throw new Error(`Safety check failed: OUT path looks wrong, refusing to write: ${OUT}`) + } + + // Read all cell directories (sorted for determinism) + const cellDirs = readdirSync(CELLS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort() + + const rawCells: { cell: unknown; slices: unknown[] }[] = [] + + for (const cellName of cellDirs) { + const cellYamlPath = join(CELLS_DIR, cellName, 'cell.yaml') + if (!existsSync(cellYamlPath)) continue + + const cellRaw: unknown = parse(readFileSync(cellYamlPath, 'utf-8')) + + // Read slices (sorted for determinism) + const slicesDir = join(CELLS_DIR, cellName, 'slices') + const slicesRaw: unknown[] = [] + if (existsSync(slicesDir)) { + const sliceNames = readdirSync(slicesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort() + for (const sliceName of sliceNames) { + const sliceYamlPath = join(slicesDir, sliceName, 'slice.yaml') + if (existsSync(sliceYamlPath)) { + slicesRaw.push(parse(readFileSync(sliceYamlPath, 'utf-8'))) + } + } + } + + rawCells.push({ cell: cellRaw, slices: slicesRaw }) + } + + const manifest = buildManifest(rawCells) + const output = renderManifestModule(manifest) + + mkdirSync(dirname(OUT), { recursive: true }) + writeFileSync(OUT, output, 'utf-8') + + console.log( + `cell-manifest: derived ${manifest.cells.length} cells → ${OUT.replace(REPO_ROOT + '/', '')}`, + ) + for (const cell of manifest.cells) { + console.log( + ` ${cell.id}: produces=${cell.produces.length} consumes=${cell.consumes.length} dependsOn=[${cell.dependsOnCells.join(',')}]`, + ) + } +} + +try { + main() +} catch (err: unknown) { + console.error(err instanceof Error ? err.message : err) + process.exit(1) +} diff --git a/tools/cell-manifest/src/types.ts b/tools/cell-manifest/src/types.ts new file mode 100644 index 0000000..0bb06e1 --- /dev/null +++ b/tools/cell-manifest/src/types.ts @@ -0,0 +1,63 @@ +/** + * Manifest shape — the tool's self-contained copy of the type set. + * + * Canonical runtime source: `packages/devboard/src/manifest/types.ts`. This tool + * is a standalone node script (mirrors `tools/codegen`, which is likewise + * self-contained) and must not reach into a package's `src/`. Drift between this + * copy and the canonical types is caught by an AI-robust **Hard** guard: the + * generated `cells.generated.ts` is annotated `: CellManifest` imported from + * devboard's `./types`, so any shape mismatch fails `@gocell/devboard typecheck` + * (excess/missing property checks on the typed const literal). + */ +export type ConsistencyLevel = 'L0' | 'L1' | 'L2' | 'L3' +export type DurabilityMode = 'durable' | 'demo' +export type ContractRole = 'serve' | 'call' | 'publish' | 'subscribe' + +export interface ContractUsage { + readonly contract: string + readonly role: ContractRole +} + +export interface CellWaiver { + readonly contract: string + readonly owner: string + readonly reason: string + readonly expiresAt: string +} + +export interface CellSlice { + readonly id: string + readonly belongsToCell: string + readonly consistencyLevel: ConsistencyLevel + readonly lifecycle: string + readonly contractUsages: readonly ContractUsage[] + readonly unitTests: readonly string[] + readonly contractTests: readonly string[] + readonly waivers: readonly CellWaiver[] +} + +export interface CellEntry { + readonly id: string + readonly name: string + readonly domain: string + readonly type: string + readonly consistencyLevel: ConsistencyLevel + readonly lifecycle: string + readonly durabilityMode: DurabilityMode + readonly owner: { readonly team: string; readonly role: string } + readonly goStructName: string + readonly schemaPrimary: string | null + readonly requires: readonly string[] + readonly l0Dependencies: readonly string[] + readonly smokeTests: readonly string[] + readonly slices: readonly CellSlice[] + readonly produces: readonly ContractUsage[] + readonly consumes: readonly ContractUsage[] + readonly dependsOnCells: readonly string[] + readonly requiredByCells: readonly string[] +} + +export interface CellManifest { + readonly cells: readonly CellEntry[] + readonly generatedFrom: string +} diff --git a/tools/cell-manifest/tsconfig.json b/tools/cell-manifest/tsconfig.json new file mode 100644 index 0000000..971c706 --- /dev/null +++ b/tools/cell-manifest/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src", "vitest.config.ts"] +} diff --git a/tools/cell-manifest/vitest.config.ts b/tools/cell-manifest/vitest.config.ts new file mode 100644 index 0000000..e6be6f1 --- /dev/null +++ b/tools/cell-manifest/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.spec.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/*.spec.ts'], + thresholds: { lines: 80, functions: 80, branches: 80, statements: 80 }, + }, + }, +})