Raiju/tests/agent-check.js
2026-10-25 06:05:42 +03:00

424 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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('<img src=x onerror="window.__xss=1">') && 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); });