const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); const base = process.env.REVIEW_URL; const password = process.env.REVIEW_PASSWORD; const shots = process.env.REVIEW_SCREENSHOTS; const imageBase64 = fs.readFileSync(process.env.REVIEW_IMAGE).toString('base64'); const siteRoot = process.env.REVIEW_SITE; const errors = []; let currentPage = null; function assert(condition, message) { if (!condition) throw new Error(message); } async function login(browser, user) { const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const page = await context.newPage(); await page.goto(base + '/admin/index.php'); await page.locator('#username').fill(user); await page.locator('#password').fill(password); await Promise.all([ page.waitForURL('**/admin/dashboard.php'), page.locator('form').first().locator('button[type=submit]').click() ]); page.on('pageerror', error => errors.push(user + ': ' + error.message)); // the dashboard is not what this checks currentPage = page; return { context, page }; } async function openAssistant(page) { await page.goto(base + '/services/test/'); await page.waitForLoadState('networkidle'); await page.locator('.rj-copilot-toggle').click(); await page.locator('#rj-copilot-panel').waitFor({ state: 'visible' }); } async function send(page, text) { const input = page.locator('.rj-copilot-input'); await input.fill(text); await input.press('Enter'); } async function waitForPackage(page) { await page.locator('.rj-agent-package:not(.rj-agent-package--applied):not(.rj-agent-package--quiet)').waitFor({ state: 'visible', timeout: 60000 }); } async function confirmModal(page) { await page.locator('.rj-copilot-modal').waitFor({ state: 'visible' }); await page.locator('.rj-copilot-modal-confirm').click(); await page.locator('.rj-copilot-modal').waitFor({ state: 'detached' }); } function newsFiles() { const dir = path.join(siteRoot, 'content/news'); return fs.existsSync(dir) ? fs.readdirSync(dir).filter(name => name.endsWith('.json')) : []; } async function mainFlow(browser, user) { const { context, page } = await login(browser, user); await openAssistant(page); // The agent is the default; the classic shortcuts are out of the way. assert(await page.locator('[data-agent-action="mode"][data-arg="agent"]').getAttribute('aria-pressed') === 'true', 'agent mode is on by default'); assert(!(await page.locator('.rj-copilot-draft').isVisible()), 'classic shortcuts are hidden in agent mode'); assert((await page.locator('.rj-agent-intro').textContent()).includes('Nothing goes live until you apply it'), 'intro explains the review'); // Mode switch await page.locator('[data-agent-action="mode"][data-arg="classic"]').click(); assert(await page.locator('.rj-copilot-draft').isVisible() && !(await page.locator('.rj-agent-intro').count()), 'classic mode shows the classic shortcuts and no agent cards'); await page.locator('[data-agent-action="mode"][data-arg="agent"]').click(); assert(await page.locator('.rj-agent-intro').count() === 1, 'agent mode is back'); // Run: the timeline, then the package await send(page, 'Update the services page and write a news post about it.'); await page.locator('.rj-agent-run').waitFor({ state: 'visible', timeout: 30000 }); await waitForPackage(page); assert((await page.locator('.rj-copilot-msg--assistant .rj-copilot-msg__bubble', { hasText: 'Updated the services page and drafted a news post' }).count()) === 1, 'what the agent prepared is also in the chat, so the history keeps it'); const card = page.locator('.rj-agent-package').first(); const text = await card.textContent(); assert(text.includes('Updated the services page and drafted a news post.'), 'the summary is shown'); assert(text.includes('Page: en_services__test') && text.includes('News post: 2026-10-10-agent-news'), 'targets are grouped: ' + text.slice(0, 300)); assert(text.includes('All checks passed'), 'validation status is shown'); assert(await page.locator('.rj-agent-steps-details summary').textContent().then(value => /\d+ steps/.test(value)), 'finished runs fold the steps'); assert(await page.locator('.rj-agent-op details').first().evaluate(node => node.open), 'a small package shows before and after right away'); assert(await page.locator('.rj-agent-diff').first().isVisible() && (await page.locator('.rj-agent-diff').first().textContent()).includes('Updated by the agent'), 'before/after is shown'); assert(!(await page.locator('.rj-agent-package script, .rj-agent-package iframe, .rj-agent-package img[src="x"]').count()), 'no markup is injected'); assert((await page.locator('.rj-agent-summary').first().textContent()).includes('') && await page.evaluate(() => window.__xss === undefined), 'text from the agent is shown as text and never runs'); await page.screenshot({ path: path.join(shots, 'agent-review-' + user + '.png') }); // A "yes" in the chat never applies anything await send(page, 'yes'); await page.locator('.rj-agent-summary', { hasText: 'Nothing is applied by a chat reply.' }).waitFor({ state: 'visible', timeout: 60000 }); assert(newsFiles().length === 0 && !(await (await page.request.get(base + '/services/test/')).text()).includes('Updated by the agent'), 'a "yes" in the chat does not apply the package'); // Preview opens the page with the staged content in this tab, for the owner only const assistantUrl = page.url(); await Promise.all([page.waitForNavigation(), page.locator('[data-agent-action="preview"]').first().click()]); assert(await page.locator('.rj-agent-preview-banner').count() === 1, 'preview banner'); assert((await page.locator('body').textContent()).includes('Updated by the agent'), 'preview shows the staged text'); assert(!(await page.locator('#admin-bar').count()), 'no editor in the preview'); assert((await page.locator('[data-rj-preview="apply"]').textContent()).trim() === 'Apply changes' && (await page.locator('[data-rj-preview="back"]').textContent()).trim() === 'Back to the assistant', 'preview bar buttons'); // "Back" returns to the page the assistant was on; the open package is still there await Promise.all([page.waitForNavigation(), page.locator('[data-rj-preview="back"]').click()]); assert(page.url().split('#')[0] === assistantUrl.split('#')[0] && !page.url().includes('rj_agent'), 'back leads to the page the person came from: ' + page.url()); await page.locator('#rj-copilot-panel').waitFor({ state: 'visible', timeout: 20000 }); await waitForPackage(page); assert(!page.url().includes('#rj-agent'), 'the hash is cleaned up'); // "Apply changes" returns and asks for the assistant's own confirmation; the preview itself applies nothing await Promise.all([page.waitForNavigation(), page.locator('[data-agent-action="preview"]').first().click()]); await Promise.all([page.waitForNavigation(), page.locator('[data-rj-preview="apply"]').click()]); await page.locator('.rj-copilot-modal').waitFor({ state: 'visible', timeout: 20000 }); assert((await page.locator('.rj-copilot-modal').textContent()).includes('Apply this package'), 'the assistant asks for the confirmation'); assert(newsFiles().length === 0 && !(await (await page.request.get(base + '/services/test/')).text()).includes('Updated by the agent'), 'nothing is applied before the confirmation'); await page.locator('.rj-copilot-modal-cancel').click(); await page.locator('.rj-copilot-modal').waitFor({ state: 'detached' }); await waitForPackage(page); return { context, page }; } (async () => { const browser = await chromium.launch({ headless: true }); try { // ---- Editor: no publish right ---------------------------------------------------------------------------------------- let { context, page } = await mainFlow(browser, 'editor'); // Request changes: the agent revises the package await page.locator('[data-agent-action="changes"]').click(); assert((await page.locator('.rj-copilot-input').getAttribute('placeholder')).includes('different'), 'the input asks for the changes'); await send(page, 'Make the services text shorter.'); await page.locator('.rj-agent-summary', { hasText: 'shorter' }).waitFor({ state: 'visible', timeout: 60000 }); assert((await page.locator('.rj-agent-package').first().textContent()).includes('Shorter text'), 'the revised package shows the new text'); // The package survives a reload await page.reload(); await page.locator('.rj-agent-package').first().waitFor({ state: 'visible', timeout: 20000 }); assert((await page.locator('.rj-agent-summary').first().textContent()).includes('shorter'), 'the review is back after a reload'); // Mobile layout await page.setViewportSize({ width: 390, height: 844 }); const overflow = await page.evaluate(() => { const list = document.querySelector('.rj-copilot-messages'); const panel = document.querySelector('.rj-copilot-panel'); return { list: list.scrollWidth - list.clientWidth, panel: panel.scrollWidth - panel.clientWidth, page: document.documentElement.scrollWidth - innerWidth }; }); assert(overflow.list <= 1 && overflow.panel <= 1 && overflow.page <= 1, 'no horizontal overflow on a phone: ' + JSON.stringify(overflow)); await page.screenshot({ path: path.join(shots, 'agent-mobile.png') }); await page.setViewportSize({ width: 1280, height: 900 }); // Apply await page.locator('[data-agent-action="apply"]').click(); await confirmModal(page); await page.locator('.rj-agent-package--applied').waitFor({ state: 'visible', timeout: 20000 }); assert(newsFiles().length === 1, 'the news post exists after the apply'); const appliedText = await page.locator('.rj-agent-package--applied').textContent(); assert(appliedText.includes('Changes applied') && appliedText.includes('Publishing needs a role with that right.'), 'editors without the right see the hint: ' + appliedText); assert(!(await page.locator('[data-agent-action="publish"]').count()), 'no publish button without the right'); const live = await page.request.get(base + '/services/test/'); assert((await live.text()).includes('Shorter text'), 'the page shows the applied text'); await page.screenshot({ path: path.join(shots, 'agent-applied-editor.png') }); // Undo await page.locator('[data-agent-action="undo"]').click(); await confirmModal(page); await page.locator('.rj-agent-package--quiet').waitFor({ state: 'visible', timeout: 20000 }); assert((await page.locator('.rj-agent-package--quiet').textContent()).includes('The package was undone.'), 'undo is confirmed'); assert(!(await (await page.request.get(base + '/services/test/')).text()).includes('Shorter text'), 'the page is back to its old text'); assert(newsFiles().length === 0, 'the news post is gone again'); // Cancel a slow run await send(page, '[flow:slow] look around'); await page.locator('.rj-agent-run__head [data-agent-action="cancel"]').waitFor({ state: 'visible', timeout: 20000 }); await page.locator('.rj-agent-run__head [data-agent-action="cancel"]').click(); await page.locator('.rj-copilot-msg__bubble', { hasText: 'The agent was cancelled' }).waitFor({ state: 'visible', timeout: 30000 }); assert(!(await page.locator('.rj-agent-run__head').count()), 'the progress card is gone after cancelling'); // Attach images with the button and by drop; the chips show thumbnails await page.setInputFiles('[data-agent-file]', process.env.REVIEW_IMAGE); await page.locator('.rj-agent-chip img').first().waitFor({ state: 'visible', timeout: 20000 }); await page.waitForFunction(() => document.querySelector('.rj-agent-chip img').naturalWidth > 0, null, { timeout: 20000 }); const dataTransfer = await page.evaluateHandle(encoded => { const transfer = new DataTransfer(); const bytes = Uint8Array.from(atob(encoded), ch => ch.charCodeAt(0)); transfer.items.add(new File([bytes], 'dropped.png', { type: 'image/png' })); return transfer; }, imageBase64); await page.dispatchEvent('.rj-copilot-panel', 'dragenter', { dataTransfer }); assert(await page.locator('.rj-copilot-panel.is-dragging').count() === 1, 'the drop overlay shows while dragging files'); await page.dispatchEvent('.rj-copilot-panel', 'drop', { dataTransfer }); await page.waitForFunction(() => document.querySelectorAll('.rj-agent-chip img').length === 2, null, { timeout: 20000 }); assert(await page.locator('.rj-copilot-panel.is-dragging').count() === 0, 'the overlay is gone after the drop'); await page.screenshot({ path: path.join(shots, 'agent-chips.png') }); await send(page, '[flow:upload] put my images on the page'); await page.locator('.rj-copilot-msg__bubble', { hasText: 'I can see your image' }).waitFor({ state: 'visible', timeout: 60000 }); assert(!(await page.locator('.rj-agent-chip').count()), 'chips are cleared once the request is sent'); // The answer is also kept when the page loses it: it comes back after a reload (a new login, a closed tab) await page.evaluate(() => { Object.keys(sessionStorage).filter(key => key.startsWith('raiju-ai-assistant:')).forEach(key => { const stored = JSON.parse(sessionStorage.getItem(key)); stored.messages = stored.messages.filter(message => !message.content.includes('I can see your image')); sessionStorage.setItem(key, JSON.stringify(stored)); }); }); await page.reload(); await page.locator('.rj-copilot-msg__bubble', { hasText: 'I can see your image' }).waitFor({ state: 'visible', timeout: 30000 }); assert((await page.locator('.rj-copilot-msg__bubble', { hasText: 'I can see your image' }).count()) === 1, 'the answer is back once, not twice'); await page.reload(); await page.locator('.rj-copilot-msg__bubble', { hasText: 'I can see your image' }).first().waitFor({ state: 'visible', timeout: 30000 }); assert((await page.locator('.rj-copilot-msg__bubble', { hasText: 'I can see your image' }).count()) === 1, 'and a second reload does not add it again'); await context.close(); // ---- Admin: may publish ------------------------------------------------------------------------------------------------------------------ ({ context, page } = await mainFlow(browser, 'admin')); // Applying from the preview: the bar leads back, the assistant asks, one confirmation applies it await Promise.all([page.waitForNavigation(), page.locator('[data-agent-action="preview"]').first().click()]); await Promise.all([page.waitForNavigation(), page.locator('[data-rj-preview="apply"]').click()]); await page.locator('.rj-copilot-modal').waitFor({ state: 'visible', timeout: 30000 }); await confirmModal(page); await page.locator('.rj-agent-package--applied').waitFor({ state: 'visible', timeout: 20000 }); assert(newsFiles().length === 1 && JSON.parse(fs.readFileSync(path.join(siteRoot, 'content/news', newsFiles()[0]), 'utf8')).hidden === true, 'new news is hidden after the apply'); await page.screenshot({ path: path.join(shots, 'agent-applied-admin.png') }); await page.locator('[data-agent-action="publish"]').first().click(); await confirmModal(page); await page.locator('.rj-agent-package--applied .rj-agent-tag--ok', { hasText: 'Published' }).waitFor({ state: 'visible', timeout: 20000 }); assert(JSON.parse(fs.readFileSync(path.join(siteRoot, 'content/news', newsFiles()[0]), 'utf8')).hidden !== true, 'publishing makes the news post visible'); await context.close(); // ---- Limit warnings: the dashboard, the settings and the assistant tell before a limit is reached ---------------------------------- const settingsFile = path.join(siteRoot, 'content/ai-settings.json'); const originalSettings = fs.readFileSync(settingsFile, 'utf8'); const tight = JSON.parse(originalSettings); tight.limits.monthlyBudgetCents = 1000; fs.writeFileSync(settingsFile, JSON.stringify(tight)); const today = new Date().toISOString().slice(0, 10); fs.writeFileSync(path.join(siteRoot, 'content/ai-usage.json'), JSON.stringify({ days: { [today]: { requests: 5, textRequests: 5, imageRequests: 0, inputTokens: 0, outputTokens: 0, estimatedCostCents: 0 } }, months: { [today.slice(0, 7)]: { requests: 5, textRequests: 5, imageRequests: 0, inputTokens: 0, outputTokens: 0, estimatedCostCents: 900 } } })); ({ context, page } = await login(browser, 'editor')); await openAssistant(page); await page.locator('.rj-copilot-limit').waitFor({ state: 'visible', timeout: 15000 }); const barText = await page.locator('.rj-copilot-limit').textContent(); assert(barText.includes('Monthly AI budget') && barText.includes('9.00 EUR of 10.00 EUR') && barText.includes('administrator'), 'the assistant shows the nearly used budget: ' + barText); await context.close(); ({ context, page } = await login(browser, 'admin')); await page.waitForSelector('#aiLimitBanner:not([hidden])', { timeout: 15000 }); assert((await page.locator('#aiLimitBanner').textContent()).includes('Monthly AI budget'), 'the dashboard shows the warning'); await page.locator('#aiLimitBanner button', { hasText: 'Adjust limits' }).click(); await page.locator('#aiLimitBannerSettings:not([hidden])').waitFor({ state: 'visible', timeout: 10000 }); const budgetBefore = Number(await page.locator('#aiMonthlyBudget').inputValue()); await page.locator('#aiLimitBannerSettings button', { hasText: 'Raise by 50' }).click(); assert(Number(await page.locator('#aiMonthlyBudget').inputValue()) === Math.ceil(budgetBefore * 1.5), 'the button raises the limit by half'); await page.screenshot({ path: path.join(shots, 'agent-limit-banner.png') }); await context.close(); fs.writeFileSync(path.join(siteRoot, 'content/ai-usage.json'), JSON.stringify({ days: {}, months: {} })); fs.writeFileSync(settingsFile, originalSettings); // ---- Dashboard assistant: cards that open an area or change the dashboard look ------------------------------------------------------- ({ context, page } = await login(browser, 'admin')); // On the home tab the chat sits in the assistant card (no bubble, nothing to open) await page.locator('#aiAssistantEmbed #rj-copilot-panel').waitFor({ state: 'visible' }); assert(!(await page.locator('.rj-copilot-toggle').isVisible()), 'the bubble gives way to the chat in the card'); // The card offers both chats, labelled; the agent is the default, the dashboard's own actions belong to Classic const modes = await page.locator('#aiAssistantEmbed .rj-agent-mode button').allTextContents(); assert(modes.join('|') === 'Agent|Classic', 'the card offers Agent and Classic: ' + modes.join('|')); assert(await page.locator('#aiAssistantEmbed .rj-agent-mode button[data-arg="agent"]').getAttribute('aria-pressed') === 'true', 'the agent is selected first'); assert(!(await page.locator('#aiChatForm').isVisible()), 'the old classic form gives way to the embedded chat'); await page.locator('#aiAssistantEmbed .rj-agent-mode button[data-arg="classic"]').click(); await send(page, 'Please switch the dashboard to dark mode'); await page.locator('.rj-copilot-dash', { hasText: 'Change dashboard theme' }).waitFor({ state: 'visible', timeout: 30000 }); assert((await page.locator('.rj-copilot-dash').first().textContent()).includes('Light → Dark'), 'the card says what changes'); const themeNow = () => JSON.parse(fs.readFileSync(path.join(siteRoot, 'content/settings.json'), 'utf8')).theme || {}; assert(themeNow().adminTheme !== 'dark', 'nothing changes before the click'); await page.screenshot({ path: path.join(shots, 'dashboard-action-card.png') }); await page.locator('.rj-copilot-dash-run').first().click(); await page.locator('.rj-copilot-dash__ok').first().waitFor({ state: 'visible', timeout: 30000 }); await page.waitForFunction(() => document.documentElement.getAttribute('data-site-theme') === 'dark', null, { timeout: 10000 }); assert(themeNow().adminTheme === 'dark', 'the theme is saved'); await send(page, 'Make the accent colour blue'); await page.locator('.rj-copilot-dash', { hasText: 'accent colour' }).waitFor({ state: 'visible', timeout: 30000 }); await page.locator('.rj-copilot-dash-run').last().click(); await page.locator('.rj-copilot-dash__ok').nth(1).waitFor({ state: 'visible', timeout: 30000 }); assert(String(themeNow().accentColor).toLowerCase() === '#0b5cad', 'the colour is saved: ' + JSON.stringify(themeNow())); await send(page, 'Where are the language settings?'); await page.locator('.rj-copilot-dash', { hasText: 'Open Settings › Language' }).waitFor({ state: 'visible', timeout: 30000 }); await page.locator('.rj-copilot-dash-run').last().click(); await page.locator('#settingsPanel-language.active').waitFor({ state: 'visible', timeout: 10000 }); await send(page, 'Switch the dashboard to German'); await page.locator('.rj-copilot-dash', { hasText: 'dashboard language' }).waitFor({ state: 'visible', timeout: 30000 }); await page.locator('.rj-copilot-dash-dismiss').last().click(); assert(!(await page.locator('.rj-copilot-dash', { hasText: 'dashboard language' }).count()), 'a dismissed card is gone and changes nothing'); assert(!(JSON.parse(fs.readFileSync(path.join(siteRoot, 'content/settings.json'), 'utf8')).general || {}).adminLanguage, 'dismissing does not change the language'); await send(page, 'Switch the dashboard to German'); await page.locator('.rj-copilot-dash', { hasText: 'dashboard language' }).waitFor({ state: 'visible', timeout: 30000 }); await Promise.all([ page.waitForNavigation({ timeout: 20000 }), page.locator('.rj-copilot-dash-run').last().click() ]); assert((JSON.parse(fs.readFileSync(path.join(siteRoot, 'content/settings.json'), 'utf8')).general || {}).adminLanguage === 'de', 'the language is saved and the dashboard reloads'); await context.close(); const resetSettings = JSON.parse(fs.readFileSync(path.join(siteRoot, 'content/settings.json'), 'utf8')); delete resetSettings.general; delete resetSettings.theme; fs.writeFileSync(path.join(siteRoot, 'content/settings.json'), JSON.stringify(resetSettings)); // ---- Places in the dashboard: a card with a link that opens in a new tab, and refusals -------------------------------------------------- ({ context, page } = await login(browser, 'admin')); await page.locator('#aiAssistantEmbed #rj-copilot-panel').waitFor({ state: 'visible' }); await page.locator('#aiAssistantEmbed .rj-agent-mode button[data-arg="classic"]').click(); await send(page, 'Please set the system language to English'); const placeCard = page.locator('.rj-copilot-dash--link', { hasText: 'Admin Language' }); await placeCard.waitFor({ state: 'visible', timeout: 30000 }); assert((await placeCard.textContent()).includes('Settings › Language › Admin Language'), 'the card names the place: ' + await placeCard.textContent()); const placeLink = placeCard.locator('a.rj-copilot-dash-link'); assert(await placeLink.getAttribute('target') === '_blank' && (await placeLink.getAttribute('rel')).includes('noopener'), 'the link opens in a new tab without the opener'); assert(await placeLink.getAttribute('href') === '/admin/dashboard.php#settings/language', 'the link leads to the language settings'); await page.screenshot({ path: path.join(shots, 'dashboard-place-card.png') }); const [popup] = await Promise.all([context.waitForEvent('page'), placeLink.click()]); await popup.waitForLoadState('domcontentloaded'); await popup.locator('#settingsPanel-language.active').waitFor({ state: 'visible', timeout: 15000 }); assert(popup.url().endsWith('#settings/language'), 'the new tab opened the language settings: ' + popup.url()); await popup.close(); await send(page, 'Delete everything on the home page'); await page.locator('.rj-copilot-msg--assistant .rj-copilot-msg__bubble', { hasText: 'few at a time' }).waitFor({ state: 'visible', timeout: 30000 }); await context.close(); // ---- Safety in the agent: a refused request, and a package with a note needs a second confirmation ------------------------------------- ({ context, page } = await login(browser, 'editor')); await openAssistant(page); await send(page, 'Please delete everything on the home page'); await page.locator('.rj-agent-note--warn', { hasText: 'few at a time' }).waitFor({ state: 'visible', timeout: 30000 }); assert(!(await page.locator('.rj-agent-run').count()), 'a refused request never starts a run'); await send(page, '[flow:risk] Link the first section to https://bit.ly/x1'); await waitForPackage(page); const riskNote = page.locator('.rj-agent-risk'); await riskNote.waitFor({ state: 'visible', timeout: 30000 }); assert((await riskNote.textContent()).includes('Safety check'), 'the safety note is shown apart from the other notes: ' + await riskNote.textContent()); await page.screenshot({ path: path.join(shots, 'agent-safety-note.png') }); await page.locator('[data-agent-action="apply"]').click(); await page.locator('.rj-copilot-modal').waitFor({ state: 'visible' }); const riskModal = await page.locator('.rj-copilot-modal').textContent(); assert(riskModal.includes('Apply despite the safety notes?') && riskModal.includes('Apply anyway'), 'the second confirmation names the safety notes: ' + riskModal); await page.locator('.rj-copilot-modal-cancel').click(); await page.locator('.rj-copilot-modal').waitFor({ state: 'detached' }); assert(!(await (await page.request.get(base + '/services/test/')).text()).includes('bit.ly/x1'), 'cancelling applies nothing'); await page.locator('[data-agent-action="apply"]').click(); await confirmModal(page); await page.locator('.rj-agent-package--applied').waitFor({ state: 'visible', timeout: 20000 }); assert((await (await page.request.get(base + '/services/test/')).text()).includes('bit.ly/x1'), 'the package is applied after the second confirmation'); await page.locator('[data-agent-action="undo"]').click(); await confirmModal(page); await page.locator('.rj-agent-package--quiet').waitFor({ state: 'visible', timeout: 20000 }); assert(!(await (await page.request.get(base + '/services/test/')).text()).includes('bit.ly/x1'), 'and it can be undone'); await context.close(); // ---- Dashboard: the agent section of the AI settings ------------------------------------------------------------------------------ ({ context, page } = await login(browser, 'admin')); await page.locator('.sidebar-nav-item[data-tab="settings"]').click(); await page.locator('.settings-tab-btn[data-settings-tab="ai"]').click(); await page.locator('#aiAgentSection').waitFor({ state: 'visible' }); await page.waitForFunction(() => document.getElementById('aiAgentEnabled').checked === true, null, { timeout: 15000 }); // The groups are folded; the save bar stays in reach however long the page is. assert(!(await page.locator('#aiBraveApiKey').isVisible()), 'the research group starts folded'); for (const summary of await page.locator('.ai-agent-details > summary').all()) await summary.click(); assert(await page.locator('#aiBraveApiKey').isVisible(), 'a folded group opens'); await page.locator('#aiAgentSection').scrollIntoViewIfNeeded(); const saveBox = await page.locator('#saveAiSettingsBtn').boundingBox(); assert(saveBox && saveBox.y + saveBox.height <= 900 + 1, 'the save button is on screen without scrolling to the end of the form: ' + JSON.stringify(saveBox)); // The status arrives with the AI settings; the overview may have filled the form a moment earlier await page.waitForFunction(() => /ready/.test(document.getElementById('aiAgentStatus').textContent), null, { timeout: 15000 }); assert((await page.locator('#aiAgentStatus').textContent()).includes('ready'), 'the status says the agent is ready: ' + await page.locator('#aiAgentStatus').textContent()); assert(await page.locator('#aiBraveApiKeyHint').textContent().then(text => /No API key stored/.test(text)), 'no research key yet'); await page.fill('#aiBraveApiKey', 'BSA-browser-brave-key-1234'); await page.fill('#aiAgentRunsPerHour', '5'); await page.fill('#aiAgentAllowDomains', 'Example.org\nhttps://www.example.net/page'); for (const [id, wanted] of [['aiAgentRight_createEvents', false], ['aiAgentRight_publish', true]]) { if (await page.locator('#' + id).isChecked() !== wanted) await page.locator('label.toggle-label', { has: page.locator('#' + id) }).click(); assert(await page.locator('#' + id).isChecked() === wanted, id + ' toggles'); } await page.screenshot({ path: path.join(shots, 'agent-settings.png'), fullPage: false }); await page.locator('#saveAiSettingsBtn').click(); await page.locator('#aiBraveApiKeyHint', { hasText: 'API key saved' }).waitFor({ state: 'visible', timeout: 20000 }); assert((await page.locator('#aiBraveApiKey').getAttribute('placeholder')) === '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022', 'a saved key shows a masked placeholder'); assert(await page.locator('#aiRemoveBraveApiKey').isVisible(), 'the remove button appears next to a saved key'); assert((await page.locator('#aiBraveApiKey').inputValue()) === '', 'the key field is empty again after saving'); assert(!(await page.content()).includes('BSA-browser-brave-key'), 'the key is never rendered back'); assert((await page.locator('#aiAgentAllowDomains').inputValue()) === 'example.org\nexample.net', 'domains are shown normalised'); const savedSettings = JSON.parse(fs.readFileSync(path.join(siteRoot, 'content/ai-settings.json'), 'utf8')); const savedSecrets = JSON.parse(fs.readFileSync(path.join(siteRoot, 'content/ai-secrets.json'), 'utf8')); assert(savedSecrets.research.brave.apiKey === 'BSA-browser-brave-key-1234' && !JSON.stringify(savedSettings).includes('BSA-browser-brave-key'), 'the key is in the secrets file, not in the settings file'); assert( savedSettings.agent.limits.runsPerHour === 5 && savedSettings.agent.roles.editor.createEvents === false && savedSettings.agent.roles.editor.publish === true, 'the form saved what was entered'); // saving again without typing the key keeps it await page.locator('#saveAiSettingsBtn').click(); await page.waitForTimeout(1500); assert(JSON.parse(fs.readFileSync(path.join(siteRoot, 'content/ai-secrets.json'), 'utf8')).research.brave.apiKey === 'BSA-browser-brave-key-1234', 'an empty key field keeps the saved key'); // The x asks first; cancelling keeps the key, confirming deletes it at once. await page.locator('#aiRemoveBraveApiKey').click(); await page.locator('#modalConfirm').waitFor({ state: 'visible' }); await page.locator('#modalOverlay .modal-actions .btn-secondary').click(); assert(JSON.parse(fs.readFileSync(path.join(siteRoot, 'content/ai-secrets.json'), 'utf8')).research.brave.apiKey === 'BSA-browser-brave-key-1234', 'cancelling keeps the key'); await page.locator('#aiRemoveBraveApiKey').click(); await page.locator('#modalConfirm').click(); await page.locator('#aiBraveApiKeyHint', { hasText: 'No API key stored' }).waitFor({ state: 'visible', timeout: 10000 }); assert(!(JSON.parse(fs.readFileSync(path.join(siteRoot, 'content/ai-secrets.json'), 'utf8')).research || {}).brave, 'confirming removes the key from the secrets file'); assert(!(await page.locator('#aiRemoveBraveApiKey').isVisible()), 'the remove button is gone without a saved key'); await page.locator('#aiAgentClearCache').click(); await page.locator('.toast, [role="status"]', { hasText: 'Cache cleared' }).first().waitFor({ state: 'visible', timeout: 10000 }); // The system status lists what the agent needs, never a key await page.locator('.sidebar-nav-item[data-tab="system"]').click().catch(() => null); const systemText = await page.evaluate(async () => (await (await fetch('api.php?action=system-status&scheme=http')).text())); assert(systemText.includes('"agent"') && !systemText.includes('BSA-browser'), 'system status without keys'); await context.close(); if (errors.length) throw new Error(errors.join('\n')); console.log('agent-check ok'); } catch (error) { if (currentPage) await currentPage.screenshot({ path: path.join(shots, 'failure.png') }).catch(() => null); throw error; } finally { await browser.close(); } })().catch(error => { console.error(error); process.exit(1); });