diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 498181ad..7d48845e 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -31,6 +31,10 @@ export interface DeckeImprovementLeg { id: string; leg: number; asked: string | export interface DeckeImprovementEvent { ordinal: number; batch: number; batchOrdinal: number; legId: string | null; kind: string; payload: unknown } export interface DeckeImprovementTurn { seq: number; asked: string; answered: string; tools: unknown; feedback: number | null; feedbackComment: string | null; offsetSeconds: number; tokens: DeckeImprovementTokens; costUsd: number | null; costCoverage: DeckeImprovementCoverage; buildSha: string | null; buildPr: number | null; finishReason: string | null; hasError: boolean; legs: DeckeImprovementLeg[]; events: DeckeImprovementEvent[] } export interface DeckeImprovementDetail { conversation: DeckeImprovementConversation; turns: DeckeImprovementTurn[] } +/** A knowledge note as a reviewer sees it (`GET /admin/knowledge`). Every text field is the proposer's, not DeckPal's. */ +export type KnowledgeNoteStatus = 'pending' | 'approved' | 'rejected' +export interface KnowledgeNote { id: number; cards: string[]; statement: string; sourceUrl: string; quote: string; status: KnowledgeNoteStatus; proposedAt: string; flags: string[]; reviewedAt: string | null; reviewReason: string | null } +export interface KnowledgeQueue { notes: KnowledgeNote[]; next: number | null } const BASE = isCloudMode ? '/api' : '/deckpal/api' @@ -2296,6 +2300,10 @@ export const api = { /** Shared Deck-E chats (pseudonymised), using the SQL reader's bucketed shape. */ adminDeckeImprovementList: (params: string, signal?: AbortSignal) => get('/admin/decke-improvement?' + params, signal), adminDeckeImprovement: (id: string, signal?: AbortSignal) => get('/admin/decke-improvement/' + encodeURIComponent(id), signal), + /** Knowledge notes waiting for (or past) review; needs knowledge.review in a browser session. */ + adminKnowledgeNotes: (params: string, signal?: AbortSignal) => get('/admin/knowledge?' + params, signal), + adminReviewKnowledgeNote: (id: number, body: { decision: 'approved' | 'rejected'; reason: string }) => + send<{ note: KnowledgeNote }>('POST', `/admin/knowledge/${id}/review`, body), adminAiConversation: (id: string, params: string, signal?: AbortSignal) => get<{ items: AiUsageDetail[]; total: number; limit: number; offset: number }>('/admin/ai-usage/conversations/' + encodeURIComponent(id) + '?' + params, signal), meFeatures: (signal?: AbortSignal) => get<{ features: FeatureAccess[] }>('/me/features', signal), setMeFeature: (key: string, optedIn: boolean, expectedRevision: number) => send<{ features: FeatureAccess[] }>('PATCH', '/me/features/' + encodeURIComponent(key), { optedIn, expectedRevision }), diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 11336288..c3f6cb58 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -92,6 +92,8 @@ const AdminAudit = lazyRoute('./routes/admin/Audit', () => import('./routes/admi const AdminFeatures = lazyRoute('./routes/admin/Features', () => import('./routes/admin/Features'), 'AdminFeatures') const AdminAiUsage = lazyRoute('./routes/admin/AiUsage', () => import('./routes/admin/AiUsage'), 'AdminAiUsage') const AdminDeckeChats = lazyRoute('./routes/admin/DeckeChats', () => import('./routes/admin/DeckeChats'), 'AdminDeckeChats') +const AdminKnowledge = lazyRoute('./routes/admin/Knowledge', () => import('./routes/admin/Knowledge'), 'KnowledgeReview') +const DevKnowledge = lazyRoute('./routes/admin/Knowledge', () => import('./routes/admin/Knowledge'), 'DevKnowledgeReview') // The two things `RootComponent` mounts on every page and almost nobody sees: // Deck-E's host (two accounts) and the support prompt (signed in, cloud only). @@ -257,6 +259,7 @@ const TOOL_PERMISSIONS: Record = { '/devtools': 'devtools.access', '/scan': 'scanner.use', '/design': 'design.view', '/dev/decke': 'diagnostics.view', '/dev/chat-ui': 'diagnostics.view', '/dev/decke-compare': 'diagnostics.view', '/dev/scan-harness': 'diagnostics.view', '/dev/quad-labeler': 'scanner.label', '/dev/quad-harvest': 'scanner.label', + '/dev/knowledge': 'knowledge.review', } function SecureOutlet() { const pathname = useRouterState({ select: s => s.location.pathname }) @@ -840,6 +843,9 @@ const adminRoute = createRoute({ getParentRoute: () => rootRoute, path: '/admin' const adminFeaturesRoute = createRoute({ getParentRoute: () => adminRoute, path: '/features', component: AdminFeatures }) const adminUsageRoute = createRoute({ getParentRoute: () => adminRoute, path: '/usage', component: AdminAiUsage }) const adminDeckeChatsRoute = createRoute({ getParentRoute: () => adminRoute, path: '/decke-chats', beforeLoad: () => requireCapability('decke.improvement.read'), component: AdminDeckeChats }) +const adminKnowledgeRoute = createRoute({ getParentRoute: () => adminRoute, path: '/knowledge', beforeLoad: () => requireCapability('knowledge.review'), component: AdminKnowledge }) +// The same review screen for a contributor given knowledge.review: /admin needs admin.access, which a contributor never holds. +const devKnowledgeRoute = createRoute({ getParentRoute: () => rootRoute, path: '/dev/knowledge', beforeLoad: () => requireCapability('knowledge.review'), component: DevKnowledge }) const adminIndexRoute = createRoute({ getParentRoute: () => adminRoute, path: '/', component: AdminOverview }) const adminUsersRoute = createRoute({ getParentRoute: () => adminRoute, path: '/users', beforeLoad: () => requireCapability('users.read'), component: AdminUsers }) const adminUserRoute = createRoute({ getParentRoute: () => adminRoute, path: '/users/$userId', beforeLoad: () => requireCapability('users.read'), component: AdminUserDetail }) @@ -857,8 +863,8 @@ const creditsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/cred }), component: Credits }) const routeTree = rootRoute.addChildren([ - devtoolsRoute, adminToolsRoute, - adminRoute.addChildren([adminFeaturesRoute, adminUsageRoute, adminDeckeChatsRoute, adminIndexRoute, adminUsersRoute, adminUserRoute, adminRolesRoute, adminSettingsRoute, adminAuditRoute]), + devtoolsRoute, adminToolsRoute, devKnowledgeRoute, + adminRoute.addChildren([adminFeaturesRoute, adminUsageRoute, adminDeckeChatsRoute, adminKnowledgeRoute, adminIndexRoute, adminUsersRoute, adminUserRoute, adminRolesRoute, adminSettingsRoute, adminAuditRoute]), creditsRoute, ...coreRoutes, designRoute, diff --git a/apps/web/src/routes/Devtools.tsx b/apps/web/src/routes/Devtools.tsx index 15df922a..d15691d6 100644 --- a/apps/web/src/routes/Devtools.tsx +++ b/apps/web/src/routes/Devtools.tsx @@ -9,6 +9,7 @@ export const DEV_TOOLS = [ { to: '/dev/scan-harness', name: 'Scanner harness', permission: 'diagnostics.view', description: 'Exercise detector fixtures and camera diagnostics.' }, { to: '/dev/quad-labeler', name: 'Quad labeler', permission: 'scanner.label', description: 'Capture and annotate training photos in the shared photo corpus.' }, { to: '/dev/quad-harvest', name: 'Training photo review', permission: 'scanner.label', description: 'Inspect captured training photos and labels.' }, + { to: '/dev/knowledge', name: 'Knowledge notes', permission: 'knowledge.review', description: 'Approve or reject the rules notes agents propose before every account sees them.' }, ] export function Devtools() { const access = useAccess() diff --git a/apps/web/src/routes/admin/Admin.tsx b/apps/web/src/routes/admin/Admin.tsx index 032ac49e..064921e3 100644 --- a/apps/web/src/routes/admin/Admin.tsx +++ b/apps/web/src/routes/admin/Admin.tsx @@ -13,6 +13,7 @@ export const ADMIN_SECTIONS = [ { key: 'audit', label: 'Audit', to: '/admin/audit', permission: 'audit.read' }, { key: 'usage', label: 'AI usage', to: '/admin/usage', permission: 'admin.access' }, { key: 'decke-chats', label: 'Deck-E chats', to: '/admin/decke-chats', permission: 'decke.improvement.read' }, + { key: 'knowledge', label: 'Knowledge notes', to: '/admin/knowledge', permission: 'knowledge.review' }, { key: 'features', label: 'Features', to: '/admin/features', permission: 'roles.manage' }, ] export default function Admin() { diff --git a/apps/web/src/routes/admin/Knowledge.tsx b/apps/web/src/routes/admin/Knowledge.tsx new file mode 100644 index 00000000..e269feb1 --- /dev/null +++ b/apps/web/src/routes/admin/Knowledge.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { api, type KnowledgeNote, type KnowledgeNoteStatus } from '../../lib/api' +import { useAccess } from '../../lib/access' +import { Button, Content, DataTable, EmptyState, Tabs } from '../../components/ui' +import { ConfirmAction, fmtDate } from './shared' + +/** + * Knowledge notes review (Phase 6): what agents proposed about how cards interact, approved or rejected here before + * every account sees it in get_card and rules_lookup. + * + * Gated on knowledge.review alone, never admin.access: a contributor role can be given review, and a contributor + * never holds admin.access. So this component does not use useAdminQuery (which requires admin.access), and it is + * mounted twice: as an Administration tab for admins, and at /dev/knowledge for contributors. + * + * Every text field is the proposer's. React escapes it; the source link opens in a new tab with no referrer and is + * shown in full, so the reviewer sees where it goes before following it. + */ +const STATUSES: { key: KnowledgeNoteStatus; label: string }[] = [ + { key: 'pending', label: 'Waiting' }, + { key: 'approved', label: 'Approved' }, + { key: 'rejected', label: 'Rejected' }, +] + +function NoteBody({ note }: { note: KnowledgeNote }) { + return
+

{note.cards.join(' · ')}

+

{note.statement}

+
“{note.quote}”
+ {note.sourceUrl} + {note.flags.length > 0 &&
    {note.flags.map(flag => +
  • {flag}
  • )}
} + {note.reviewReason &&

Review note: {note.reviewReason}

} +
+} + +export function KnowledgeReview() { + const access = useAccess() + const [status, setStatus] = useState('pending') + const [before, setBefore] = useState(null) + const [deciding, setDeciding] = useState<{ note: KnowledgeNote; decision: 'approved' | 'rejected' } | null>(null) + const allowed = access.ready && access.permissions.includes('knowledge.review') + const params = new URLSearchParams({ status, limit: '50', ...(before ? { before: String(before) } : {}) }).toString() + // The key starts with 'admin' so ConfirmAction's save refreshes it after a decision. + const query = useQuery({ queryKey: ['admin', access.identity, access.revision, 'knowledge', params], queryFn: ({ signal }) => api.adminKnowledgeNotes(params, signal), enabled: allowed, retry: false, staleTime: 0 }) + if (!allowed) return + return
+

Knowledge notes

+

Findings agents proposed about how cards interact. An approved note appears for every account in card details and rules answers, under the note’s source. Check the quote against the source before approving; reject anything wrong, unsourced or personal.

+ { setStatus(key as KnowledgeNoteStatus); setBefore(null) }} items={STATUSES} /> + String(note.id)} getRowLabel={note => note.cards.join(', ')} + loading={query.isPending} refreshing={query.isFetching && !query.isPending} error={query.error?.message} onRetry={() => void query.refetch()} + empty={} + columns={[ + { id: 'note', header: 'Note', className: 'min-w-[320px] max-w-[640px]', cell: note => }, + { id: 'proposed', header: 'Proposed', className: 'min-w-[150px]', cell: note => }, + status === 'pending' + ? { id: 'decide', header: 'Decision', className: 'min-w-[200px]', cell: note =>
+ + +
} + : { id: 'reviewed', header: 'Decided', className: 'min-w-[150px]', cell: note => note.reviewedAt ? : '—' }, + ]} /> + {query.data?.next && } + {before && } + {deciding && api.adminReviewKnowledgeNote(deciding.note.id, { decision: deciding.decision, reason })} + close={() => setDeciding(null)} />} +
+} + +/** /dev/knowledge: the same screen for a contributor, who has Dev tools but not Administration. */ +export function DevKnowledgeReview() { + return
+} diff --git a/apps/web/src/routes/admin/shared.tsx b/apps/web/src/routes/admin/shared.tsx index eee42646..d9151a79 100644 --- a/apps/web/src/routes/admin/shared.tsx +++ b/apps/web/src/routes/admin/shared.tsx @@ -39,8 +39,9 @@ export function useAdminSave() { } return { busy, error, success, save } } -export function ConfirmAction({ title, description, action, close, reasonRequired = true }: { - title: string; description: string; action: (reason: string) => Promise; close: () => void; reasonRequired?: boolean +/** `reasonRequired` (default) demands a reason; `reasonOptional` shows the field without demanding it; neither hides it. */ +export function ConfirmAction({ title, description, action, close, reasonRequired = true, reasonOptional = false }: { + title: string; description: string; action: (reason: string) => Promise; close: () => void; reasonRequired?: boolean; reasonOptional?: boolean }) { const [reason, setReason] = useState('') const state = useAdminSave() @@ -48,6 +49,7 @@ export function ConfirmAction({ title, description, action, close, reasonRequire return { if (state.busy) return false; close() }}>

{description}

{reasonRequired && setReason(e.target.value)} hint="Recorded in the administrative audit log." />} + {!reasonRequired && reasonOptional && setReason(e.target.value)} hint="Recorded in the administrative audit log." />} {state.error && {state.error}}
diff --git a/decisions/2026/2026-10-26-knowledge-notes-are-reviewed-in-administration-and-in-dev-tools.md b/decisions/2026/2026-10-26-knowledge-notes-are-reviewed-in-administration-and-in-dev-tools.md new file mode 100644 index 00000000..c7c502af --- /dev/null +++ b/decisions/2026/2026-10-26-knowledge-notes-are-reviewed-in-administration-and-in-dev-tools.md @@ -0,0 +1,33 @@ +--- +date: "2026-10-26" +title: "Knowledge notes are reviewed in Administration and in Dev tools" +decided_by: "Chey (via Claude)" +areas: ["web", "admin"] +supersedes: [] +--- +## 2026-10-26 — Knowledge notes are reviewed in Administration and in Dev tools +**Decided by:** Chey (via Claude) + +**Decision:** The review screen for knowledge notes (Phase 6, part two) is one component (`routes/admin/Knowledge.tsx`), mounted in two places: +- **Administration:** the "Knowledge notes" tab at `/admin/knowledge`, for admins. +- **Dev tools:** the "Knowledge notes" entry at `/dev/knowledge`, for anyone else holding `knowledge.review`. + +Both routes require `knowledge.review` and nothing else. The screen does not use `useAdminQuery`, which requires `admin.access`. + +On the screen, a reviewer: +- sees what is waiting, approved or rejected; +- reads each note's cards, statement, quote, full source link and automatic-check flags; +- approves (a reason is optional); +- or rejects (a reason is required). + +Each decision is final and audited (#358). + +**Why:** +- The owner asked for an administration surface that "users/admins of a certain reasonable tier" can reach. #358 lets a contributor role (tier 30) be given `knowledge.review`. +- `/admin` requires `admin.access`, which a contributor can never hold (the tier ceiling). A tab only under Administration would make that grant useless. +- Contributors already reach Dev tools. + +**Implications:** +- The source link opens in a new tab with `noopener noreferrer nofollow` and is printed in full, so the reviewer sees where it goes first. All proposer text renders as text. +- The browser suite covers the screen at 1280 px: text stays text, flags show, a rejection without a reason is not sent, and decided notes move to their tab. +- The chunk is precached like the other Administration screens. Only dev-only routes are excluded from the precache. diff --git a/tests/browser/admin.mjs b/tests/browser/admin.mjs index 32bd0a5b..451534ff 100644 --- a/tests/browser/admin.mjs +++ b/tests/browser/admin.mjs @@ -26,10 +26,16 @@ export function adminFixture(mount) { state.audit = Array.from({ length: 36 }, (_, i) => ({ id: 'audit-' + i, actorId: i % 2 ? USER : OWNER, actorName: i % 2 ? 'Future Contributor' : 'Owner', action: i % 3 ? 'role.update' : 'user.suspend', targetType: 'user', targetId: i % 2 ? USER : OWNER, before: { suspended: false }, after: { suspended: true, note: '' }, reason: 'Reviewed change ' + i, createdAt: new Date(Date.parse(now) - i * 60000).toISOString() })) state.orders = Array.from({ length: 38 }, (_, i) => ({ id: 'credit-order-' + i, userId: i % 2 ? USER : OWNER, username: i % 2 ? 'Future Contributor' : 'Owner', packName: 'Starter', credits: 500, priceCents: 500, currency: 'usd', status: i % 3 === 0 ? 'refunded' : 'paid', refundedCents: i % 3 === 0 ? 100 : 0, reversedCredits: i % 3 === 0 ? 100 : 0, disputeStatus: i === 0 ? 'closed' : null, createdAt: now, paidAt: now })) state.ledger = Array.from({ length: 32 }, (_, i) => ({ id: 'ledger-' + i, delta: i % 2 ? -5 : 20, debtDelta: i === 0 ? -10 : 0, kind: i % 2 ? 'usage' : 'adjustment', reason: 'Ledger review ' + i, createdAt: now, pricingRevision: 1 })) + // Knowledge notes waiting for review: one carries markup (it must stay text), one a reviewer flag. + state.knowledge = [ + { id: 4, cards: ['jamming tower'], statement: 'Jamming Tower stops the effects of Pokémon Tools in play, both players\' alike.', sourceUrl: 'https://www.pokemon.com/us/pokemon-tcg/rules', quote: 'Pokémon Tools attached to each Pokémon (both yours and your opponent\'s) have no effect.', status: 'pending', proposedAt: now, flags: [], reviewedAt: null, reviewReason: null }, + { id: 3, cards: ['nighttime mine', 'dragapult ex'], statement: 'Nighttime Mine makes Phantom Dive cost one more Colorless. ', sourceUrl: 'https://www.pokemon.com/us/pokemon-tcg/rules', quote: 'Attacks used by each Tera Pokémon in play cost one more Colorless Energy.', status: 'pending', proposedAt: now, flags: [], reviewedAt: null, reviewReason: null }, + { id: 2, cards: ['boss\'s orders'], statement: 'Boss\'s Orders can bring up a Pokémon with no Retreat Cost.', sourceUrl: 'https://example.com/forum/thread', quote: 'Switch in 1 of your opponent\'s Benched Pokémon.', status: 'pending', proposedAt: now, flags: ['source is not a rulings site reviewers know'], reviewedAt: null, reviewReason: null }, + ] initFeedback(state, PERMISSIONS) const allowMutation = (pathname, method) => { const rel = pathname.slice(mount.length) - return ['POST','PUT','PATCH','DELETE'].includes(method) && ((state.chatProbe && ['/api/chat','/api/decke/history'].includes(rel)) || /^\/api\/(?:me\/(?:features\/[^/]+|decke-sharing|settings)|admin\/(?:features\/[^/]+|users\/[^/]+\/(?:role|ai-override)))$/.test(rel) || /^\/api\/admin\/(roles(?:\/[^/]+)?|settings|users\/[^/]+\/(roles|status|revoke-tokens)|credits\/(settings|packs(?:\/[^/]+)?|users\/[^/]+\/(adjustments|resolve-hold)))$/.test(rel) || rel === '/api/me/credits/checkout' || (method === 'POST' && rel === '/api/me/billing/visit')) + return ['POST','PUT','PATCH','DELETE'].includes(method) && ((state.chatProbe && ['/api/chat','/api/decke/history'].includes(rel)) || /^\/api\/(?:me\/(?:features\/[^/]+|decke-sharing|settings)|admin\/(?:features\/[^/]+|users\/[^/]+\/(?:role|ai-override)))$/.test(rel) || /^\/api\/admin\/(roles(?:\/[^/]+)?|settings|users\/[^/]+\/(roles|status|revoke-tokens)|credits\/(settings|packs(?:\/[^/]+)?|users\/[^/]+\/(adjustments|resolve-hold)))$/.test(rel) || rel === '/api/me/credits/checkout' || (method === 'POST' && rel === '/api/me/billing/visit') || (method === 'POST' && /^\/api\/admin\/knowledge\/\d+\/review$/.test(rel))) } const response = (rel, url, req = { method: 'GET' }) => { const { method, body } = req @@ -144,6 +150,18 @@ export function adminFixture(mount) { const orders = state.orders.filter(order => (!user || order.userId === user) && (!status || order.status === status)) return ok({orders:orders.slice(offset,offset+limit),total:orders.length,offset,limit}) } + if (rel === '/api/admin/knowledge') { + const status = url.searchParams.get('status') ?? 'pending' + return ok({ notes: state.knowledge.filter(n => n.status === status), next: null }) + } + if (/^\/api\/admin\/knowledge\/\d+\/review$/.test(rel)) { + assert.equal(method, 'POST') + assert.deepEqual(Object.keys(body).sort(), ['decision', 'reason']) + const note = state.knowledge.find(n => n.id === Number(rel.split('/')[4])) + if (!note || note.status !== 'pending') return { status: 404, body: { error: { message: 'That note is not waiting for review.' } } } + Object.assign(note, { status: body.decision, reviewedAt: now, reviewReason: body.reason || null }) + return ok({ note }) + } if (rel === '/api/admin/audit') { const actor = url.searchParams.get('actor'), action = url.searchParams.get('action'), target = url.searchParams.get('target'), limit = Number(url.searchParams.get('limit') ?? 25), offset = Number(url.searchParams.get('offset') ?? 0) const events = state.audit.filter(event => (!actor || event.actorId === actor) && (!action || event.action === action) && (!target || event.targetId === target)) @@ -514,6 +532,43 @@ async function checkAdminTables(browser, server, mount, label, out, fixture, wid assert.equal(await auditTable.locator('tbody > tr').count(),36) results.push({case:'admin-audit-table-exact-filters-safe-keyboard-details',label,width}) + if(width===1280){ + // Knowledge notes review: proposer text stays text, flags show, approve needs no reason, reject needs one. + state.permissions=[...PERMISSIONS,'knowledge.review'] + await go(page,'/admin/knowledge') + const notes=page.getByRole('table',{name:'Knowledge notes',exact:true}) + await notes.getByText('Boss\'s Orders can bring up a Pokémon with no Retreat Cost.',{exact:true}).waitFor() + const kn=id=>state.knowledge.find(n=>n.id===id) + assert.equal(await notes.locator('tbody > tr').count(),3) + assert.equal(await page.evaluate(()=>window.fixtureUnsafe),undefined,'A note\'s text must stay text') + await notes.getByText('source is not a rulings site reviewers know',{exact:true}).waitFor() + assert.equal(await notes.getByRole('link',{name:'https://www.pokemon.com/us/pokemon-tcg/rules',exact:true}).first().getAttribute('rel'),'noopener noreferrer nofollow') + await notes.getByRole('button',{name:'Reject',exact:true}).last().click() + const reject=page.getByRole('dialog',{name:'Reject note'}) + await reject.getByRole('button',{name:'Reject note',exact:true}).click() + assert.equal(kn(2).status,'pending','A rejection without a reason is not sent') + await reject.getByLabel('Reason').fill('Not a ruling; a forum guess.') + await reject.getByRole('button',{name:'Reject note',exact:true}).click() + await reject.waitFor({state:'detached'}) + assert.deepEqual([kn(2).status,kn(2).reviewReason],['rejected','Not a ruling; a forum guess.']) + // Approve with a reason (the field is offered, not demanded), then approve another without one. + await notes.getByRole('button',{name:'Approve',exact:true}).first().click() + const approve=page.getByRole('dialog',{name:'Approve note'}) + await approve.getByLabel('Reason (optional)').fill('Matches the rulebook.') + await approve.getByRole('button',{name:'Approve note',exact:true}).click() + await approve.waitFor({state:'detached'}) + assert.deepEqual([kn(4).status,kn(4).reviewReason],['approved','Matches the rulebook.']) + await notes.getByRole('button',{name:'Approve',exact:true}).click() + await approve.getByRole('button',{name:'Approve note',exact:true}).click() + await approve.waitFor({state:'detached'}) + assert.deepEqual([kn(3).status,kn(3).reviewReason],['approved',null]) + await page.getByText('Nothing waiting',{exact:true}).waitFor() + await page.getByRole('button',{name:'Rejected',exact:true}).click() + await notes.getByText('Review note: Not a ruling; a forum guess.',{exact:true}).waitFor() + state.permissions=[...PERMISSIONS] + results.push({case:'admin-knowledge-review-approve-reject-text-safe',label,width}) + } + await go(page,'/admin/settings') const packs=page.getByRole('region',{name:'Credit packs',exact:true}), packTable=packs.getByRole('table') await packs.getByRole('status').filter({hasText:'1–25 of'}).waitFor();assert.equal(await packTable.locator('tbody > tr').count(),25) diff --git a/tests/browser/case-inventory.json b/tests/browser/case-inventory.json index 88684707..1bf88949 100644 --- a/tests/browser/case-inventory.json +++ b/tests/browser/case-inventory.json @@ -35,6 +35,8 @@ "{\"case\":\"admin-audit-table-exact-filters-safe-keyboard-details\",\"label\":\"cloud\",\"width\":390}", "{\"case\":\"admin-audit-table-exact-filters-safe-keyboard-details\",\"label\":\"selfhost\",\"width\":1280}", "{\"case\":\"admin-audit-table-exact-filters-safe-keyboard-details\",\"label\":\"selfhost\",\"width\":390}", + "{\"case\":\"admin-knowledge-review-approve-reject-text-safe\",\"label\":\"cloud\",\"width\":1280}", + "{\"case\":\"admin-knowledge-review-approve-reject-text-safe\",\"label\":\"selfhost\",\"width\":1280}", "{\"case\":\"admin-ledger-table-fixed-pagination-debt\",\"label\":\"cloud\",\"width\":1280}", "{\"case\":\"admin-ledger-table-fixed-pagination-debt\",\"label\":\"cloud\",\"width\":390}", "{\"case\":\"admin-ledger-table-fixed-pagination-debt\",\"label\":\"selfhost\",\"width\":1280}",