Files
stack/packages/webui/tests/limits-browser.test.mjs

99 lines
9.7 KiB
JavaScript

// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): Settings shows each token limit as the bus's checks
// report it. A limit no switched-on check counts reads "not enforced"; Reset posts the limit's
// path alone; a form save keeps the token limits as read.
import test from 'node:test';
import assert from 'node:assert/strict';
import {browser} from './browser.mjs';
import {startServer} from '../src/serve.mjs';
import {close} from './fixture.mjs';
const SINCE='2026-10-18T07:00:00.000Z';
const hostile='<img src=x onerror="window.injected=true">';
const tokens={instances:{coder:{limit:100,since:SINCE}},keys:{k1:{limit:5,since:SINCE}},providers:{anthropic:{limit:9,since:SINCE}},models:{anthropic:{'claude-opus-5-5':{limit:7,since:SINCE}}}};
const launch={business:'acme',revision:'a'.repeat(64),models:{opus:{provider:'anthropic',model:'claude-opus-5-5'}},limits:{total:4,tokens},instances:{coder:{modelKey:'opus'}},usage:{total:0,providers:{},models:{}}};
const TOKENS=[
{path:['tokens','instances','coder'],limit:100,since:SINCE,status:'counted',check:'token-limit',count:120,kinds:{input:10,output:20,cacheWrite:40,cacheRead:50},unknownTurns:2},
{path:['tokens','keys','k1'],limit:5,since:SINCE,status:'not-enforced',reason:'no records name a key'},
{path:['tokens','providers','anthropic'],limit:9,since:SINCE,status:'not-enforced',reason:null},
{path:['tokens','models','anthropic','claude-opus-5-5'],limit:7,since:SINCE,status:'check-failed',check:hostile,code:'check-threw'},
];
test('REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced and check failed; Reset posts one path; a save keeps the token limits',{timeout:90000},async()=>{
let answer={revision:launch.revision,limits:launch.limits,tokens:TOKENS};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},limits:async()=>answer};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');window.fixture=${JSON.stringify(launch)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Response(JSON.stringify({...fixture,revision:'b'.repeat(64)}));}return original(url,options);};`});
const port=web.address().port;
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
const cells=()=>b.evaluate("[...document.querySelectorAll('#token-limits-table tbody tr')].map(row=>[...row.querySelectorAll('th,td')].map(c=>c.textContent))");
// Read-only: every status as its words, and Reset disabled.
await b.navigate(`http://127.0.0.1:${port}/#/settings/tokens`);
await wait('!!document.querySelector("#token-limits-table")');
assert.deepEqual(await cells(),[
['tokens / instances / coder','100',SINCE,'120 (input 10, output 20, cache write 40, cache read 50); unknown turns 2','Reset'],
['tokens / keys / k1','5',SINCE,'not enforced: no records name a key','Reset'],
['tokens / providers / anthropic','9',SINCE,'not enforced','Reset'],
['tokens / models / anthropic / claude-opus-5-5','7',SINCE,`check failed: check-threw (${hostile})`,'Reset'],
]);
assert.equal(await b.evaluate('[...document.querySelectorAll("[data-limit-reset]")].every(e=>e.disabled)'),true);
assert.equal(await b.evaluate('document.querySelectorAll("#token-limits-daily").length'),0,'no daily row, no daily line');
assert.equal(await b.evaluate('window.injected===true'),false);
// Signed-in send mode: Reset posts the revision and that one path, and nothing else. A draft
// typed before the reset stays, and its save carries the revision the reset returned (row 167).
await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings/tokens`);
await wait('document.querySelector("[data-limit-reset]")?.disabled===false');
await b.evaluate('location.hash="#/settings/limits"'); await wait('!!document.querySelector("#launch-settings-form")');
await b.evaluate(`(()=>{const e=[...document.querySelectorAll('[data-setting]')].find(e=>e.dataset.setting===${JSON.stringify(JSON.stringify(['total']))});e.value='3';e.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
await b.evaluate('location.hash="#/settings/tokens"'); await wait('document.querySelector("[data-limit-reset]")?.disabled===false');
await b.evaluate('document.querySelectorAll("[data-limit-reset]")[1].click();true');
await wait('document.querySelector("#token-limits-receipt")?.textContent.includes("Limit reset.")');
assert.deepEqual(await b.evaluate('posts'),[{revision:'a'.repeat(64),reset:[['tokens','keys','k1']]}]);
// A form save sends the token limits as read, so it never drops or edits them.
await b.evaluate('location.hash="#/settings/limits"'); await wait('!!document.querySelector("#launch-settings-form")');
assert.equal(await b.evaluate(`[...document.querySelectorAll('[data-setting]')].find(e=>e.dataset.setting===${JSON.stringify(JSON.stringify(['total']))}).value`),'3');
await b.evaluate('document.querySelector("#launch-settings-form").requestSubmit();true');
await wait('posts.length===2');
const [,save]=await b.evaluate('posts');
assert.equal(save.revision,'b'.repeat(64));
assert.equal(save.limits.total,3);
assert.deepEqual(save.limits.tokens,tokens);
// Row 126 (#1620): a daily quota row adds one line under the table. Its own query string makes
// this a full load, and the next block's /#/settings one too.
answer={revision:launch.revision,limits:launch.limits,tokens:[...TOKENS,{path:['tokens','daily','jason-max'],limit:40,since:SINCE,status:'counted',check:'daily-quota',count:3,kinds:{input:1,output:1,cacheWrite:0,cacheRead:1},unknownTurns:0}]};
await b.navigate(`http://127.0.0.1:${port}/?daily=1#/settings/tokens`);
await wait('!!document.querySelector("#token-limits-daily")');
assert.equal(await b.evaluate('document.querySelector("#token-limits-daily").textContent'),"Daily quotas count only this stack's own use of the account, not its use anywhere else.");
assert.equal(await b.evaluate('document.querySelectorAll("#token-limits-daily").length'),1);
// A limits answer the server refuses shows no rows and no count.
answer={revision:launch.revision,limits:launch.limits};
await b.navigate(`http://127.0.0.1:${port}/#/settings/tokens`);
await wait('document.querySelector("#token-limits-title")?.parentElement.textContent.includes("Token limits unavailable")');
assert.equal(await b.evaluate('document.querySelectorAll("#token-limits-table").length'),0);
}finally{await b.close();await close(web);}
});
// Row 167 (#1664): the reset and the Assistant section share one snapshot. With an assistant
// draft open, a reset gives the assistant submit the revision the reset returned.
test('Row 167 with an assistant draft open, a token limit reset gives the assistant submit its revision',{timeout:90000},async()=>{
const withAssistant={...launch,assistants:{assistant:{definition:'assistant',vars:{model:'claude-opus-5-5'},tracker:null}}};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},limits:async()=>({revision:launch.revision,limits:launch.limits,tokens:TOKENS})};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch:withAssistant})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.fixture=${JSON.stringify(withAssistant)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Response(JSON.stringify({...fixture,revision:'bc'[posts.length-1].repeat(64)}));}return original(url,options);};`});
const q=sel=>`document.querySelector(${JSON.stringify(sel)})`;
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/assistant`);
await wait(`${q('.assistant-form button[type=submit]')}?.disabled===false`);
await b.evaluate(`(()=>{const e=${q('.assistant-form [data-assistant-field="tracker"]')};e.value='{"project":"P19"}';e.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
await b.evaluate('location.hash="#/settings/tokens"'); await wait('document.querySelector("[data-limit-reset]")?.disabled===false');
await b.evaluate('document.querySelectorAll("[data-limit-reset]")[1].click();true');
await wait('document.querySelector("#token-limits-receipt")?.textContent.includes("Limit reset.")');
assert.deepEqual(await b.evaluate('posts'),[{revision:'a'.repeat(64),reset:[['tokens','keys','k1']]}]);
await b.evaluate('location.hash="#/settings/assistant"'); await wait('!!document.querySelector(".assistant-form")');
assert.equal(await b.evaluate(`${q('.assistant-form [data-assistant-field="tracker"]')}.value`),'{"project":"P19"}');
await b.evaluate(`${q('.assistant-form')}.requestSubmit();true`);
await wait(`${q('[data-assistant-receipt]')}?.textContent.includes("Assistant settings saved")`);
assert.deepEqual(await b.evaluate('posts[1]'),{revision:'b'.repeat(64),assistants:{assistant:{tracker:{project:'P19'}}}});
}finally{await b.close();await close(web);}
});