152 lines
14 KiB
JavaScript
152 lines
14 KiB
JavaScript
// AI settings in the dashboard: legacy installs, effort per purpose, agent offer, own prices, key removal.
|
|
const { chromium } = require('playwright');
|
|
const fs = require('fs'); const path = require('path');
|
|
const base = process.env.REVIEW_URL, password = process.env.REVIEW_PASSWORD, site = process.env.REVIEW_SITE;
|
|
const assert = (c, m) => { if (!c) throw new Error('ASSERT: ' + m); };
|
|
(async () => {
|
|
// teach the registry the mock host
|
|
const capsPath = path.join(site, 'includes/ai/model-capabilities.json');
|
|
const caps = JSON.parse(fs.readFileSync(capsPath, 'utf8'));
|
|
const host = new URL(base).hostname;
|
|
const mk = (model, tier) => ({ provider: 'openai-compatible', host: '127.0.0.1', model, label: model, tier, effort: { param: 'openai', levels: ['low', 'medium', 'high'], recommended: { chat: 'medium', text: 'low' }, providerDefault: 'medium' }, omitTemperature: true });
|
|
caps.models.push(mk('gpt-4.1', 'power'), mk('gpt-4.1-mini', 'light'));
|
|
fs.writeFileSync(capsPath, JSON.stringify(caps));
|
|
const settingsPath = path.join(site, 'content/ai-settings.json');
|
|
{ const f = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); delete f.reasoning; delete f.providerModels; f.agent = Object.assign(f.agent || {}, { enabled: true }); fs.writeFileSync(settingsPath, JSON.stringify(f)); } // an install from before the setting
|
|
const browser = await chromium.launch();
|
|
const context = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
|
|
const page = await context.newPage();
|
|
const errors = []; page.on('pageerror', e => errors.push(e.message));
|
|
await page.goto(base + '/admin/index.php');
|
|
await page.locator('#username').fill('admin'); await page.locator('#password').fill(password);
|
|
await Promise.all([page.waitForURL('**/admin/dashboard.php'), page.locator('form').first().locator('button[type=submit]').click()]);
|
|
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.waitForTimeout(600);
|
|
// legacy install with a known model: extra choice, nothing sent
|
|
assert(await page.locator('#aiChatEffort').inputValue() === 'legacy', 'legacy install shows "Previous behavior"');
|
|
assert((await page.locator('#aiChatEffort option').allTextContents()).some(t => /Previous behavior/.test(t)), 'legacy option is listed');
|
|
// turn the agent off, save: no offer (model unchanged)
|
|
await page.locator('label.toggle-label', { has: page.locator('#aiAgentEnabled') }).click();
|
|
await page.locator('#saveAiSettingsBtn').click();
|
|
await page.waitForTimeout(1500);
|
|
assert(JSON.parse(fs.readFileSync(settingsPath, 'utf8')).agent.enabled === false, 'agent is off');
|
|
assert(JSON.parse(fs.readFileSync(settingsPath, 'utf8')).reasoning.chat === 'legacy', 'saving untouched keeps legacy');
|
|
assert(!(await page.locator('#modalOverlay').isVisible()), 'no question when the model did not change');
|
|
// change to a powerful model, choose a level, save: question appears
|
|
await page.fill('#aiChatModel', 'gpt-4.1'); await page.dispatchEvent('#aiChatModel', 'change');
|
|
assert((await page.locator('#aiChatEffort option').allTextContents()).join('|') === 'Recommended (Medium)|Low|Medium|High', 'levels offered, no legacy for another model');
|
|
await page.selectOption('#aiChatEffort', 'high');
|
|
await page.selectOption('#aiTextEffort', 'low').catch(() => {});
|
|
await page.locator('#saveAiSettingsBtn').click();
|
|
await page.locator('#modalOverlay').waitFor({ state: 'visible', timeout: 10000 });
|
|
assert(/Enable the AI agent/.test(await page.locator('#modalTitle').textContent()), 'agent question is shown: ' + await page.locator('#modalText').textContent());
|
|
let saved = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
assert(saved.reasoning.chat === 'high' && saved.chatModel === 'gpt-4.1' && saved.providerModels['openai-compatible'].reasoning.chat === 'high', 'chat level and model saved per provider');
|
|
await page.locator('#modalConfirm').click();
|
|
await page.waitForTimeout(1500);
|
|
saved = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
assert(saved.agent.enabled === true, 'confirming enables the agent');
|
|
assert(saved.reasoning.chat === 'high', 'level survives the second save');
|
|
// asked only once per model
|
|
await page.locator('label.toggle-label', { has: page.locator('#aiAgentEnabled') }).click();
|
|
await page.locator('#saveAiSettingsBtn').click();
|
|
await page.waitForTimeout(1500);
|
|
assert(!(await page.locator('#modalOverlay').isVisible()), 'not asked again for the same model');
|
|
// reload: the level is shown again
|
|
await page.reload();
|
|
await page.locator('.sidebar-nav-item[data-tab="settings"]').click();
|
|
await page.locator('.settings-tab-btn[data-settings-tab="ai"]').click();
|
|
await page.waitForTimeout(800);
|
|
assert(await page.locator('#aiChatEffort').inputValue() === 'high' && await page.locator('#aiChatModel').inputValue() === 'gpt-4.1', 'settings survive a reload');
|
|
// price: enter, save, reload
|
|
await page.fill('#aiPriceIn_chat', '3'); await page.fill('#aiPriceOut_chat', '9');
|
|
await page.locator('#saveAiSettingsBtn').click(); await page.waitForTimeout(1500);
|
|
saved = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
assert(saved.pricing.models['openai-compatible|gpt-4.1'].input === 3 && saved.pricing.models['openai-compatible|gpt-4.1'].currency === 'USD', 'own price saved');
|
|
assert((await page.locator('#aiPriceRow_chat [data-price-status]').textContent()).startsWith('Own price: 3 / 9 USD'), 'status shows own price');
|
|
await page.locator('#aiPriceRemove_chat').click(); await page.locator('#saveAiSettingsBtn').click(); await page.waitForTimeout(1500);
|
|
assert(!JSON.parse(fs.readFileSync(settingsPath, 'utf8')).pricing.models['openai-compatible|gpt-4.1'], 'own price removed');
|
|
// Image service: its own section, key and price, apart from the chat provider
|
|
await page.locator('#aiImageServiceSection').scrollIntoViewIfNeeded();
|
|
assert(await page.locator('#aiImageService').inputValue() === 'inherit' && !(await page.locator('#aiImageServiceKeyGroup').isVisible()), 'images follow the chat provider by default, no key field');
|
|
await page.selectOption('#aiImageService', 'openai-compatible');
|
|
// The chat provider of this check is a local mock, not api.openai.com: its key must not be offered for OpenAI images.
|
|
assert((await page.locator('#aiImageApiKeyHint').textContent()).startsWith('No API key stored for this service'), 'a chat key for another host is not used for OpenAI images: ' + await page.locator('#aiImageApiKeyHint').textContent());
|
|
await page.selectOption('#aiImageService', 'fal');
|
|
assert(await page.locator('#aiImageServiceModelGroup').isVisible() && await page.locator('#aiImageServiceKeyGroup').isVisible(), 'an own service shows model and key');
|
|
assert(await page.locator('#aiImageServiceModel').getAttribute('placeholder') === 'google/nano-banana-2.1', 'the default model is the placeholder');
|
|
assert((await page.locator('#aiImageApiKeyHint').textContent()).startsWith('No API key stored for this service'), 'no key for fal yet: ' + await page.locator('#aiImageApiKeyHint').textContent());
|
|
await page.waitForFunction(() => /could not be loaded/.test(document.getElementById('aiImageServiceModelHint').textContent), null, { timeout: 15000 });
|
|
await page.locator('#aiImageServiceModel').fill('fal-ai/brand-new-model');
|
|
assert(await page.locator('#aiImageServiceNotice').isVisible() && /not been checked/.test(await page.locator('#aiImageServiceNotice').textContent()), 'an unverified model is marked');
|
|
assert(await page.locator('#aiPriceRow_image').isVisible() && /default price of 5 cents/.test(await page.locator('#aiPriceRow_image [data-price-status]').textContent()), 'the image price row shows the default price');
|
|
await page.fill('#aiImageApiKey', 'fal-test-key-0123456789');
|
|
await page.fill('#aiPriceImage', '0.04');
|
|
await page.locator('#aiPriceImageDetails summary').click();
|
|
await page.fill('#aiPriceRes_4K', '0.12'); await page.fill('#aiPriceQual_high', '0.2');
|
|
assert(/also set/.test(await page.locator('#aiPriceRow_image [data-price-status]').textContent()), 'tables are announced in the status: ' + await page.locator('#aiPriceRow_image [data-price-status]').textContent());
|
|
await page.locator('#saveAiSettingsBtn').click();
|
|
await page.waitForTimeout(1500);
|
|
if (await page.locator('#modalOverlay').isVisible()) { await page.locator('#modalConfirm').click(); await page.waitForTimeout(800); }
|
|
saved = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
assert(saved.imageService.provider === 'fal' && saved.imageService.model === 'fal-ai/brand-new-model' && !JSON.stringify(saved).includes('fal-test-key'), 'image service saved, key not in the settings file');
|
|
assert(saved.pricing.imageModels['fal|fal-ai/brand-new-model'].perImage === 0.04 && saved.pricing.imageModels['fal|fal-ai/brand-new-model'].byResolution['4K'] === 0.12 && saved.pricing.imageModels['fal|fal-ai/brand-new-model'].byQuality.high === 0.2, 'image price saved per model, with the tables');
|
|
assert(await page.locator('#aiPriceRes_4K').inputValue() === '0.12' && await page.locator('#aiPriceQual_high').inputValue() === '0.2' && await page.locator('#aiPriceImage').inputValue() === '0.04', 'the tables come back after saving');
|
|
const imageSecrets = JSON.parse(fs.readFileSync(path.join(site, 'content/ai-secrets.json'), 'utf8'));
|
|
assert(imageSecrets.imageServices.fal.apiKey === 'fal-test-key-0123456789', 'image key in the secrets file');
|
|
assert(await page.locator('#aiImageApiKey').getAttribute('placeholder') === '•'.repeat(8) && await page.locator('#aiRemoveImageApiKey').isVisible() && (await page.locator('#aiImageApiKeyHint').textContent()).startsWith('Own key saved'), 'image key is masked, x visible');
|
|
assert(!(await page.content()).includes('fal-test-key'), 'the key is never in the page');
|
|
await page.locator('#aiRemoveImageApiKey').click();
|
|
await page.locator('#modalConfirm').click();
|
|
await page.waitForTimeout(1200);
|
|
assert(!JSON.parse(fs.readFileSync(path.join(site, 'content/ai-secrets.json'), 'utf8')).imageServices, 'image key removed from the secrets file');
|
|
assert(!(await page.locator('#aiRemoveImageApiKey').isVisible()) && (await page.locator('#aiImageApiKeyHint').textContent()).startsWith('No API key stored for this service'), 'form shows no image key: ' + await page.locator('#aiImageApiKeyHint').textContent() + ' / remove visible: ' + await page.locator('#aiRemoveImageApiKey').isVisible());
|
|
await page.selectOption('#aiImageService', 'custom');
|
|
assert(await page.locator('#aiImageCustomGroup').isVisible() && await page.locator('#aiImageExtraGroup').isVisible(), 'custom service shows base URL, size mode and parameters');
|
|
await page.selectOption('#aiImageService', 'inherit');
|
|
assert(!(await page.locator('#aiImageServiceModelGroup').isVisible()), 'back to the chat provider: nothing else to fill in');
|
|
// Saved again, so the checks that follow find the images with the chat provider (an own service without a key would switch the image tool off)
|
|
await page.locator('#saveAiSettingsBtn').click();
|
|
await page.waitForTimeout(1500);
|
|
if (await page.locator('#modalOverlay').isVisible()) { await page.locator('#modalConfirm').click(); await page.waitForTimeout(800); }
|
|
assert(JSON.parse(fs.readFileSync(settingsPath, 'utf8')).imageService.provider === 'inherit', 'the image service is back at the chat provider');
|
|
// provider key: x with confirmation
|
|
assert(await page.locator('#aiApiKey').getAttribute('placeholder') === '•'.repeat(8) && await page.locator('#aiRemoveApiKey').isVisible(), 'provider key is masked, x visible');
|
|
await page.locator('#aiRemoveApiKey').click(); await page.locator('#modalConfirm').click(); await page.waitForTimeout(1200);
|
|
const secrets = JSON.parse(fs.readFileSync(path.join(site, 'content/ai-secrets.json'), 'utf8'));
|
|
assert(!(secrets.providerCredentials || {})['openai-compatible'], 'provider key removed from the secrets file');
|
|
assert(!(await page.locator('#aiRemoveApiKey').isVisible()) && (await page.locator('#aiApiKeyHint').textContent()).startsWith('No API key'), 'form shows no key');
|
|
// The assistant button goes with the key and comes back without a reload
|
|
assert(await page.locator('.rj-copilot').evaluate(el => getComputedStyle(el).display) === 'none', 'the assistant button is gone when the key is removed');
|
|
await page.reload();
|
|
await page.locator('.sidebar-nav-item[data-tab="settings"]').click();
|
|
await page.locator('.settings-tab-btn[data-settings-tab="ai"]').click();
|
|
await page.waitForTimeout(600);
|
|
assert((await page.locator('.rj-copilot').count()) === 0, 'a page loaded without a key has no assistant button');
|
|
await page.fill('#aiApiKey', 'sk-test-key-0123456789abcdef');
|
|
await page.locator('#saveAiSettingsBtn').click();
|
|
await page.locator('#modalOverlay').waitFor({ state: 'visible', timeout: 10000 });
|
|
assert(/Enable the AI agent/.test(await page.locator('#modalTitle').textContent()), 'a new key gets the agent question again, even for a model that was asked about before');
|
|
await page.locator('.rj-copilot').waitFor({ state: 'visible', timeout: 10000 }); // its script is loaded after the save
|
|
assert((await page.locator('.rj-copilot').count()) === 1 && await page.locator('.rj-copilot').isVisible(), 'the assistant button appears without a reload');
|
|
await page.locator('#modalConfirm').click();
|
|
await page.waitForTimeout(1500);
|
|
// Classic mode can be switched off: the chat then shows the agent only
|
|
await page.locator('#aiAgentSection').scrollIntoViewIfNeeded();
|
|
assert(await page.locator('#aiAgentClassicMode').isChecked(), 'the classic mode is offered by default');
|
|
await page.locator('label.toggle-label', { has: page.locator('#aiAgentClassicMode') }).click();
|
|
await page.locator('#saveAiSettingsBtn').click();
|
|
await page.waitForTimeout(1500);
|
|
assert(JSON.parse(fs.readFileSync(settingsPath, 'utf8')).agent.classicMode === false, 'the choice is saved');
|
|
await page.goto(base + '/');
|
|
await page.waitForFunction(() => window.RaijuAiCopilot, null, { timeout: 15000 });
|
|
await page.evaluate(() => window.RaijuAiCopilot.open());
|
|
await page.waitForTimeout(800);
|
|
assert(await page.evaluate(() => { const m = document.querySelector('.rj-agent-mode'); return !m || getComputedStyle(m).display === 'none'; }), 'the chat has no mode switch without the classic mode');
|
|
if (errors.length) throw new Error(errors.join('\n'));
|
|
console.log('ai-settings-check ok');
|
|
await browser.close();
|
|
})().catch(e => { console.error(e); process.exit(1); });
|