@getopencrawl/cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import fs from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { spawn } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const script = fileURLToPath(new URL('../bin/opencrawl.mjs', import.meta.url));
11
+ const names = ['opencrawl_list_conversations', 'opencrawl_read_conversation', 'opencrawl_conversation_context'];
12
+
13
+ test('stdio forwards bounded conversation tools and structured errors without rewriting evidence', async () => {
14
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-mcp-conversation-'));
15
+ const calls = [];
16
+ const version = 'a'.repeat(64);
17
+ const selector = { ref: 't2', start_char: 12, end_char: 24 };
18
+ const context = 'Quoted source: 日本語 🌲\n\u001b[31m';
19
+ const receipt = { version, context, selected: [selector], included: [{ ...selector, citation: 'C1' }],
20
+ omitted: [], context_bytes: Buffer.byteLength(context), budget: 700, budget_unit: 'utf8_bytes' };
21
+ const error = { code: 'snapshot_changed', message: 'Read the conversation again before selecting evidence.' };
22
+ const server = http.createServer(async (request, response) => {
23
+ let raw = '';for await (const part of request) raw += part;
24
+ const message = JSON.parse(raw);
25
+ calls.push({ path: request.url, authorization: request.headers.authorization,
26
+ protocol: request.headers['mcp-protocol-version'], message });
27
+ if (!Object.hasOwn(message, 'id')) { response.writeHead(202);response.end();return; }
28
+ let result;
29
+ if (message.method === 'initialize') result = { protocolVersion: '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: 'fixture', version: '1' } };
30
+ else if (message.method === 'tools/list') result = { tools: names.map(name => ({ name, inputSchema: { type: 'object' } })) };
31
+ else {
32
+ const failed = message.params.arguments.version === 'b'.repeat(64);
33
+ const value = failed ? error : receipt;
34
+ result = { content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value, isError: failed };
35
+ }
36
+ response.writeHead(200, { 'Content-Type': 'application/json' });
37
+ response.end(JSON.stringify({ jsonrpc: '2.0', id: message.id, result }));
38
+ });
39
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
40
+ try {
41
+ for (const token of ['oc_synthetic_api_only', 'oca_synthetic_oauth_only']) {
42
+ const env = { ...process.env };
43
+ for (const key of Object.keys(env)) if (key.startsWith('OPENCRAWL_')) delete env[key];
44
+ Object.assign(env, { OPENCRAWL_CONFIG_DIR: path.join(directory, 'isolated'),
45
+ OPENCRAWL_URL: `http://127.0.0.1:${server.address().port}`, OPENCRAWL_API_KEY: token });
46
+ const args = { document_id: 'doc_synthetic', version, branch: 'selected', selections: [selector], budget: 700 };
47
+ const messages = [
48
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25' } },
49
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
50
+ { jsonrpc: '2.0', id: 2, method: 'tools/list' },
51
+ ...names.map((name, index) => ({ jsonrpc: '2.0', id: index + 3, method: 'tools/call', params: { name, arguments: args } })),
52
+ { jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: names[2], arguments: { ...args, version: 'b'.repeat(64) } } },
53
+ ];
54
+ const before = calls.length;
55
+ const result = await new Promise((resolve, reject) => {
56
+ const child = spawn(process.execPath, [script, 'mcp'], { cwd: directory, env, stdio: ['pipe', 'pipe', 'pipe'] });
57
+ let stdout = '', stderr = '';const timer = setTimeout(() => child.kill('SIGKILL'), 10000);
58
+ child.stdout.on('data', part => { stdout += part; });child.stderr.on('data', part => { stderr += part; });
59
+ child.once('error', reject);child.once('close', code => { clearTimeout(timer);resolve({ code, stdout, stderr }); });
60
+ child.stdin.end(messages.map(message => JSON.stringify(message)).join('\n') + '\n');
61
+ });
62
+ assert.equal(result.code, 0);assert.equal(result.stderr, '');assert.ok(!result.stdout.includes(token));
63
+ const responses = result.stdout.trim().split('\n').map(line => JSON.parse(line));
64
+ assert.deepEqual(responses.map(value => value.id), [1, 2, 3, 4, 5, 6]);
65
+ assert.deepEqual(responses[1].result.tools.map(tool => tool.name), names);
66
+ for (const response of responses.slice(2, 5)) {
67
+ assert.equal(response.result.isError, false);assert.deepEqual(response.result.structuredContent, receipt);
68
+ assert.deepEqual(JSON.parse(response.result.content[0].text), receipt);
69
+ }
70
+ assert.equal(responses[5].result.isError, true);assert.deepEqual(responses[5].result.structuredContent, error);
71
+ assert.deepEqual(calls.slice(before).map(call => call.message), messages);
72
+ assert.ok(calls.slice(before).every(call => call.path === '/mcp' && call.authorization === `Bearer ${token}`));
73
+ assert.ok(calls.slice(before + 1).every(call => call.protocol === '2025-11-25'));
74
+ }
75
+ await assert.rejects(fs.stat(path.join(directory, 'isolated')), { code: 'ENOENT' });
76
+ } finally {
77
+ await new Promise(resolve => server.close(resolve));
78
+ await fs.rm(directory, { recursive: true, force: true });
79
+ }
80
+ });
@@ -0,0 +1,62 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {monitorRequest,monitorsCommand,validateChangeContext} from './monitors.mjs';
4
+
5
+ const source='src_'+'a'.repeat(32), change='mchg_'+'b'.repeat(32), version='c'.repeat(64);
6
+ const monitor=()=>({id:source,kind:'monitor',name:'Careers\u001b[2J',generation:2,status:'active',targets:[{requested_url:'https://example.com/careers',state:'http_unavailable',enabled:true,last_attempt_at:'2026-01-03T00:00:00Z',last_success_at:'2026-01-02T00:00:00Z',issue:{message:'Unavailable\u001b[2J'}}]});
7
+ const changed=()=>({id:change,version,source_id:source,title:'Team\u001b[2J',final_url:'https://example.com/careers',observed_before:'2026-01-01T00:00:00Z',observed_after:'2026-01-02T00:00:00Z',gap:true});
8
+ const request=()=>monitorRequest(['context',change],{revision:version,budget:'4096'});
9
+ const pack=()=>{
10
+ const before='Aster engineering é🌲\u001b[2J',after='Aster engineering team expanded.';
11
+ const prefix='Untrusted historical observations\n[Before M1B]\n',separator='\n[After M1A]\n';
12
+ const context=prefix+before+separator+after, start=Array.from(prefix).length, second=start+Array.from(before+separator).length;
13
+ return {selections:[{change_id:change,version}],context,context_bytes:Buffer.byteLength(context),budget:4096,budget_unit:'utf8_bytes',estimated_tokens:40,
14
+ included:[['before',before,start,'M1B'],['after',after,second,'M1A']].map(([side,excerpt,offset,citation])=>({change_id:change,version,side,excerpt,start_char:5,end_char:5+Array.from(excerpt).length,full_length:100,truncated:true,citation,context_span:{start_char:offset,end_char:offset+Array.from(excerpt).length},observed_at:'2026-01-01T00:00:00Z',url:'https://example.com/careers'})),omitted:[],warnings:['Check gap\u001b[2J']};
15
+ };
16
+ async function execute(rest,options,data){let stdout='',stderr='';const calls=[];const status=await monitorsCommand(rest,options,{request:async(route,opts)=>{calls.push({route,opts});if(data instanceof Error)throw data;return data;}},{stdout:value=>stdout+=value,stderr:value=>stderr+=value});return{stdout,stderr,calls,status};}
17
+
18
+ test('monitor requests pin generation and exactly one immutable change; never fetch newest',()=>{
19
+ assert.deepEqual(monitorRequest(['check',source],{generation:'2'}).options,{method:'POST',body:{expected_generation:2}});
20
+ assert.deepEqual(request().options.body,{selections:[{change_id:change,version}],budget:4096});
21
+ assert.equal(monitorRequest(['changes'],{source,cursor:'opaque_-',limit:'3'}).route,`/api/v1/monitor-changes?limit=3&source_id=${source}&cursor=opaque_-`);
22
+ assert.equal(monitorRequest(['list'],{limit:'2',offset:'4'}).route,'/api/v1/monitors?limit=2&offset=4');
23
+ for(const generation of [undefined,'0','-1','1e2','02','9007199254740992'])assert.throws(()=>monitorRequest(['check',source],{generation}));
24
+ for(const options of [{},{revision:version.toUpperCase()},{revision:version,budget:'16385'},{revision:version,budget:'255'},{revision:version,source},{revision:version,selection:[]}])assert.throws(()=>monitorRequest(['context',change],options));
25
+ for(const rest of [['context',change,'extra'],['show','../private'],['check'],['list',source],['create']])assert.throws(()=>monitorRequest(rest,{}));
26
+ assert.throws(()=>monitorRequest(['changes'],{cursor:'x'.repeat(257)}));
27
+ });
28
+ test('exact historical stdout and complete JSON receipt preserve Unicode/control bytes',async()=>{
29
+ const data=pack(),result=await execute(['context',change],{revision:version,budget:'4096'},data);
30
+ assert.equal(result.status,0);assert.equal(result.stdout,data.context);assert.equal(result.calls.length,1);assert.deepEqual(result.calls[0].opts.body,request().options.body);
31
+ assert.match(result.stderr,/2\/2 sides included/);assert.match(result.stderr,/truncated/);assert.match(result.stderr,/\\u001b/);assert.ok(!result.stderr.includes('\u001b'));
32
+ const json=await execute(['context',change],{revision:version,json:true},data);assert.deepEqual(JSON.parse(json.stdout),data);assert.equal(json.stderr,'');
33
+ });
34
+ test('empty budget receipt and explicit omitted side do not imply a removal',async()=>{
35
+ const data=pack();data.context='';data.context_bytes=0;data.included=[];data.omitted=['before','after'].map(side=>({change_id:change,version,side,reason:'budget'}));
36
+ const result=await execute(['context',change],{revision:version},data);assert.equal(result.status,0);assert.equal(result.stdout,'');assert.match(result.stderr,/0\/2/);assert.match(result.stderr,/before side omitted/);assert.match(result.stderr,/after side omitted/);
37
+ });
38
+ test('receipt validation refuses changed identities, incomplete partition, incorrect Unicode spans and byte overruns before output',async()=>{
39
+ const original=pack();const cases=[{selections:[]},{selections:[{change_id:change,version:'d'.repeat(64)}]},{budget:5000},{budget_unit:'tokens'},{context_bytes:1},
40
+ {context:'x'.repeat(4097),context_bytes:4097},{included:[original.included[0]],omitted:[]},
41
+ {included:[original.included[0],original.included[0]]},{included:[{...original.included[0],change_id:'mchg_'+'f'.repeat(32)},original.included[1]]},
42
+ {included:[{...original.included[0],context_span:{start_char:0,end_char:2}},original.included[1]]},
43
+ {included:[{...original.included[0],end_char:9000},original.included[1]]},{included:[],omitted:[{change_id:change,version,side:'before',reason:'missing'}]}];
44
+ for(const replacement of cases){let out='';await assert.rejects(monitorsCommand(['context',change],{revision:version},{request:async()=>({...pack(),...replacement})},{stdout:v=>out+=v,stderr:()=>{}}));assert.equal(out,'');}
45
+ validateChangeContext(pack(),request());
46
+ });
47
+ test('monitor displays escape controls and distinguish latest attempt from last good observation',async()=>{
48
+ const show=await execute(['show',source],{},monitor());assert.ok(!show.stdout.includes('\u001b'));assert.match(show.stdout,/\\u001b/);assert.match(show.stdout,/2026-01-03.*last good observation: 2026-01-02/);assert.match(show.stderr,/not evidence of removal/);
49
+ const list=await execute(['list'],{}, {items:[monitor()],limit:20,offset:0,has_more:true});assert.match(list.stderr,/--offset 1/);
50
+ const changes=await execute(['changes'],{source},{items:[changed()],limit:20,next_cursor:'opaque'});assert.match(changes.stdout,/Version: c{64}/);assert.match(changes.stdout,/failed checks/);assert.ok(!changes.stdout.includes('\u001b'));assert.match(changes.stderr,/--source src_/);
51
+ await assert.rejects(execute(['changes'],{source},{items:[{...changed(),source_id:'src_'+'f'.repeat(32)}],limit:20,next_cursor:null}));
52
+ });
53
+ test('check only returns accepted job and never polls or assumes all pages succeeded',async()=>{
54
+ const job={id:'job_test',kind:'monitor',status:'queued'};const result=await execute(['check',source],{generation:'2'},{job});
55
+ assert.equal(result.calls.length,1);assert.match(result.stderr,/does not confirm page coverage/);assert.match(result.stderr,/jobs wait job_test/);assert.match(result.stdout,/queued/);
56
+ });
57
+ test('fixed conflict/auth/retention errors never echo provider payloads or automatically retry',async()=>{
58
+ for(const [status,code,pattern] of [[409,'monitor_changed',/current generation/],[409,'monitor_change_changed',/no newer change/],[409,'monitor_evidence_changed',/access changed/],[409,'monitor_not_active',/not active/],[404,'arbitrary',/retained history/],[401,null,/Sign in/],[500,'secret',/No automatic retry/]]){
59
+ const error=Object.assign(new Error('RAW SECRET\u001b[2J'),{status,code});const result=await execute(['context',change],{revision:version,json:true},error);
60
+ assert.equal(result.status,1);assert.equal(result.calls.length,1);assert.match(JSON.parse(result.stdout).error.message,pattern);assert.equal(result.stderr,'');assert.ok(!result.stdout.includes('SECRET'));
61
+ }
62
+ });
@@ -0,0 +1,140 @@
1
+ /** Offline DOM/fake-browser controls: no provider, browser or API calls. */
2
+ import {test} from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import vm from 'node:vm';
8
+ import {PassThrough} from 'node:stream';
9
+ import {loginPrompt,loginScreenReady,waitForLogin,monitorRunner} from './product-monitors.mjs';
10
+
11
+ const fixture=JSON.parse(await fs.readFile(new URL('./fixtures/product-monitor-contract.json',import.meta.url),'utf8'));
12
+ const screen=fixture.monitor.pages[0];
13
+ const pendingPrompt=(_message,{signal})=>new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true}));
14
+ test('real readline fallback accepts Enter or aborts and removes stream listeners',async()=>{
15
+ for(const cancel of [false,true]){
16
+ const input=new PassThrough(),output=new PassThrough(),controller=new AbortController();input.isTTY=true;
17
+ const before=input.eventNames().map(n=>[n,input.listenerCount(n)]);
18
+ const wait=loginPrompt('Sign in in the browser. ',{signal:controller.signal,input,output});
19
+ if(cancel){controller.abort();await assert.rejects(wait,{code:'cancelled'});}else{input.write('\n');await wait;}
20
+ assert.deepEqual(input.eventNames().map(n=>[n,input.listenerCount(n)]),before);input.destroy();output.destroy();
21
+ }
22
+ });
23
+ test('real terminal readline Ctrl+C reports cancellation and restores raw mode without process SIGINT',{timeout:1000},async()=>{
24
+ const input=new PassThrough(),output=new PassThrough(),rawModes=[];input.isTTY=true;output.isTTY=true;output.columns=80;input.setRawMode=value=>rawModes.push(value);
25
+ let processSignals=0,decoderListeners;const observed=()=>{processSignals++;};process.on('SIGINT',observed);
26
+ try{
27
+ for(let attempt=0;attempt<2;attempt++){
28
+ const wait=loginPrompt('Sign in in the browser. ',{input,output});input.write('\u0003');await assert.rejects(wait,{code:'cancelled'});
29
+ assert.equal(input.listenerCount('keypress'),0);assert.equal(input.isPaused(),true);
30
+ // Node retains its shared keypress decoder on the stream, not a question listener.
31
+ if(attempt===0)decoderListeners=input.listenerCount('data');else assert.equal(input.listenerCount('data'),decoderListeners);
32
+ }
33
+ assert.equal(processSignals,0);assert.deepEqual(rawModes,[true,false,true,false]);
34
+ }finally{process.off('SIGINT',observed);input.destroy();output.destroy();}
35
+ });
36
+ function domReady(change=()=>{}){
37
+ const node=(text='')=>({innerText:text,style:{display:'block',visibility:'visible',opacity:'1'},getBoundingClientRect:()=>({width:100,height:30})});
38
+ const rows=new Map([[screen.ready_selector,[node()]],[screen.account_marker.selector,[node('Example account')]]]);
39
+ const context={location:{href:screen.url},document:{querySelectorAll:s=>rows.get(s)||[]},getComputedStyle:n=>n.style,argument:{screen,expected:'Example account'}};
40
+ change(context,rows,node);
41
+ return vm.runInNewContext('('+loginScreenReady.toString()+')(argument)',context);
42
+ }
43
+ test('automatic hint requires exact URL, unique visible readiness/scope and matching required account',()=>{
44
+ assert.equal(domReady(),true);
45
+ for(const change of [
46
+ c=>{c.location.href=screen.url.replace('#overview','#other');},
47
+ c=>{c.location.href='https://login.example/';},
48
+ c=>{c.argument={screen:{...screen,account_marker:null},expected:'Example account'};},
49
+ c=>{c.argument.expected='Wrong account';},
50
+ (_c,r,n)=>r.set(screen.login_selector,[n()]),
51
+ (_c,r)=>r.set(screen.ready_selector,[]),
52
+ (_c,r,n)=>r.set(screen.ready_selector,[n(),n()]),
53
+ (c,r)=>{c.argument.screen={...screen,scope_selector:'.missing'};r.delete('.missing');},
54
+ (_c,r)=>r.get(screen.account_marker.selector)[0].style.visibility='hidden',
55
+ (_c,r)=>r.get(screen.account_marker.selector)[0].style.opacity='0',
56
+ (_c,r,n)=>r.set(screen.account_marker.selector,[n('Example account'),n('Example account')]),
57
+ (_c,r)=>r.get(screen.account_marker.selector)[0].getBoundingClientRect=()=>({width:0,height:0}),
58
+ ])assert.equal(domReady(change),false);
59
+ });
60
+ test('automatic hint cancels the pending manual prompt and returns no page content',async()=>{
61
+ const external=new AbortController();let promptSignal,evaluations=0;
62
+ const page={url:()=>screen.url,isClosed:()=>false,evaluate:async(fn,args)=>{evaluations++;assert.equal(fn,loginScreenReady);assert.deepEqual(args,{screen,expected:'Example account'});return true;}};
63
+ const result=await waitForLogin(page,screen,{[screen.id]:'Example account'},external.signal,(message,options)=>{assert.match(message,/continue when ready/);promptSignal=options.signal;return pendingPrompt(message,options);});
64
+ assert.equal(result,undefined);assert.equal(evaluations,1);assert.equal(promptSignal.aborted,true);assert.equal(external.signal.aborted,false);
65
+ });
66
+ test('marker-free screen requires deliberate Enter fallback without a DOM read',async()=>{
67
+ let entered=0;const page={url:()=>screen.url,isClosed:()=>false,evaluate:async()=>assert.fail('no automatic marker-free completion')};
68
+ await waitForLogin(page,{...screen,account_marker:null},{},new AbortController().signal,async()=>{entered++;});assert.equal(entered,1);
69
+ });
70
+ test('transient auth navigation is polled without navigating or collecting content',async()=>{
71
+ let ready=false,reads=0;const page={url:()=>ready?screen.url:'https://login.example/',isClosed:()=>false,evaluate:async()=>{reads++;return true;}};
72
+ await waitForLogin(page,screen,{[screen.id]:'Example account'},new AbortController().signal,(message,options)=>{ready=true;return pendingPrompt(message,options);});assert.equal(reads,1);
73
+ });
74
+
75
+ async function local(fn){
76
+ const directory=await fs.mkdtemp(path.join(os.tmpdir(),'product-login-')),prior=process.env.OPENCRAWL_CONFIG_DIR;process.env.OPENCRAWL_CONFIG_DIR=directory;
77
+ const m=structuredClone(fixture.monitor);m.pages[0].latest_snapshot=null;
78
+ const markers=path.join(directory,'markers.json');await fs.writeFile(markers,JSON.stringify({[screen.id]:'Example account'}));
79
+ const events=[],client={baseUrl:'https://opencrawl.example',request:async(route,options={})=>{events.push([route,options.method||'GET']);return route==='/api/auth/me'?{user:{id:'usr_'+'1'.repeat(32)},workspace:{id:'ws_'+'2'.repeat(32)}}:m;}};
80
+ const file=path.join(directory,'product-monitor-profiles/test/binding.json');
81
+ const run=(browser,overrides={})=>monitorRunner('login',m,client,{profile:'test',markers},{playwright:browser.pw,waitForLogin:pendingPrompt,...overrides});
82
+ const locksGone=async()=>{const entries=await fs.readdir(directory,{recursive:true});assert.equal(entries.some(x=>path.basename(x)==='lock'),false);assert.equal(events.some(x=>x[1]!=='GET'),false);};
83
+ const listeners=['SIGINT','SIGTERM'].map(s=>process.listenerCount(s));
84
+ try{await fn({directory,m,client,events,file,run,locksGone});}
85
+ finally{assert.deepEqual(['SIGINT','SIGTERM'].map(s=>process.listenerCount(s)),listeners);if(prior===undefined)delete process.env.OPENCRAWL_CONFIG_DIR;else process.env.OPENCRAWL_CONFIG_DIR=prior;await fs.rm(directory,{recursive:true,force:true});}
86
+ }
87
+ function browser({auto=true,headlessError,onScreenshot,onLaunch,onGoto,closed=false}={}){
88
+ const contexts=[],evaluations=[],navigations=[];const png=Buffer.from(fixture.capture_request.screenshot_png_base64,'base64');
89
+ const pw={chromium:{launchPersistentContext:async(_directory,options)=>{
90
+ assert.ok(options.timeout>0&&options.timeout<=30000);await onLaunch?.(options);
91
+ const context={headless:options.headless,closes:0,route:async()=>{},routeWebSocket:async()=>{},pages:()=>[],close:async()=>{context.closes++;},newPage:async()=>{
92
+ let url='about:blank';return {on(){},close:async()=>{},isClosed:()=>closed,route:async()=>{},routeWebSocket:async()=>{},mainFrame:()=>({}),url:()=>url,
93
+ goto:async value=>{url=value;navigations.push({value,headless:options.headless});await onGoto?.(options);return {status:()=>200};},locator:()=>({waitFor:async()=>{}}),
94
+ evaluate:async(fn,args)=>{evaluations.push(fn.name);if(fn===loginScreenReady)return auto;if(args)return headlessError?{error:headlessError}:{text:'Observed workflow',title:'Overview',marker:'Example account'};if(String(fn).includes('__opencrawlChanged'))return false;},
95
+ screenshot:async()=>{onScreenshot?.();return png;}};
96
+ }};contexts.push(context);return context;
97
+ }}};return {pw,contexts,evaluations,navigations};
98
+ }
99
+ test('automatic completion still verifies every configured screen, then saves once with no upload',()=>local(async x=>{
100
+ x.m.pages.push({...x.m.pages[0],id:'ppg_'+'3'.repeat(32),account_marker:null,url:'https://product.example/second'});
101
+ const b=browser();assert.equal((await x.run(b)).state,'session_saved_locally');assert.deepEqual(b.contexts.map(c=>[c.headless,c.closes]),[[false,1],[true,1]]);
102
+ assert.deepEqual(b.navigations.map(x=>x.value),[screen.url,screen.url,'https://product.example/second']);assert.equal(b.evaluations.filter(x=>x==='extractScreen').length,2);
103
+ const saved=JSON.parse(await fs.readFile(x.file));assert.equal(saved.markers[screen.id],'Example account');await x.locksGone();
104
+ }));
105
+ test('an automatic hint never overrides headless account or masking failure',()=>local(async x=>{
106
+ for(const error of ['context_mismatch','mask_unresolved','request_blocked']){const b=browser({headlessError:error});await assert.rejects(x.run(b),new RegExp(error));assert.equal(b.contexts.length,2);assert.ok(b.contexts.every(c=>c.closes===1));await assert.rejects(fs.stat(x.file),{code:'ENOENT'});await x.locksGone();}
107
+ }));
108
+ test('authority change after headed readiness refuses before verification or binding',()=>local(async x=>{
109
+ const b=browser();const request=x.client.request;x.client.request=async(route,options)=>{const r=await request(route,options);return route==='/api/auth/me'&&b.evaluations.length?{...r,user:{id:'usr_'+'4'.repeat(32)}}:r;};
110
+ await assert.rejects(x.run(b),/identity_changed/);assert.equal(b.contexts.length,1);assert.equal(b.contexts[0].closes,1);await assert.rejects(fs.stat(x.file),{code:'ENOENT'});await x.locksGone();
111
+ }));
112
+ test('login timeout closes owned headed context, cancels prompt and releases both locks',()=>local(async x=>{
113
+ const b=browser({auto:false});let promptSignal;await assert.rejects(x.run(b,{loginTimeoutMs:20,waitForLogin:(message,options)=>{promptSignal=options.signal;return pendingPrompt(message,options);}}),/login_timeout/);
114
+ assert.equal(promptSignal.aborted,true);assert.equal(b.contexts.length,1);assert.equal(b.contexts[0].closes,1);await assert.rejects(fs.stat(x.file),{code:'ENOENT'});await x.locksGone();
115
+ }));
116
+ test('external cancellation and SIGTERM stop login without a headless attempt or saved binding',()=>local(async x=>{
117
+ for(const mode of ['external','SIGTERM']){const signal=new AbortController(),b=browser({auto:false});await assert.rejects(x.run(b,{signal:signal.signal,waitForLogin:(message,options)=>{queueMicrotask(()=>mode==='external'?signal.abort():process.emit('SIGTERM'));return pendingPrompt(message,options);}}),/cancelled/);assert.equal(b.contexts.length,1);assert.equal(b.contexts[0].closes,1);await assert.rejects(fs.stat(x.file),{code:'ENOENT'});await x.locksGone();}
118
+ }));
119
+ test('closed login window stops instead of leaving an indefinite manual prompt',()=>local(async x=>{
120
+ const b=browser({closed:true});await assert.rejects(x.run(b),/login_window_closed/);assert.equal(b.contexts[0].closes,1);await x.locksGone();
121
+ }));
122
+ test('deadline reached during launch closes the returned context without navigating',()=>local(async x=>{
123
+ const b=browser({onLaunch:()=>new Promise(r=>setTimeout(r,20))});await assert.rejects(x.run(b,{loginTimeoutMs:5}),/login_timeout/);assert.equal(b.contexts[0].closes,1);assert.deepEqual(b.navigations,[]);await x.locksGone();
124
+ }));
125
+ test('cancellation during verification preserves an earlier valid binding',()=>local(async x=>{
126
+ await x.run(browser());const prior=await fs.readFile(x.file);const signal=new AbortController(),b=browser({onScreenshot:()=>signal.abort()});
127
+ await assert.rejects(x.run(b,{signal:signal.signal}),/cancelled/);assert.deepEqual(await fs.readFile(x.file),prior);assert.ok(b.contexts.every(c=>c.closes===1));await x.locksGone();
128
+ }));
129
+ test('cancellation during binding fsync refuses the commit and removes only the new temporary file',()=>local(async x=>{
130
+ await x.run(browser());const prior=await fs.readFile(x.file),signal=new AbortController(),open=fs.open;
131
+ fs.open=async(...args)=>{const h=await open(...args);if(String(args[0]).startsWith(x.file+'.')){const sync=h.sync.bind(h);h.sync=async()=>{await sync();signal.abort();};}return h;};
132
+ try{await assert.rejects(x.run(browser(),{signal:signal.signal}),/cancelled/);}finally{fs.open=open;}
133
+ assert.deepEqual(await fs.readFile(x.file),prior);assert.deepEqual((await fs.readdir(path.dirname(x.file))).filter(x=>x.endsWith('.tmp')),[]);await x.locksGone();
134
+ }));
135
+
136
+
137
+ test('v3 automatic hint uses the fixed opener before the picker exists and still requires the marker',()=>{
138
+ const ready=change=>domReady((c,r,n)=>{c.argument.capabilities=true;c.argument.screen={...screen,ready_selector:'[data-testid="connect-integration-open"]',scope_selector:'[data-testid="connect-integration"]'};r.set(c.argument.screen.ready_selector,[n()]);change?.(c,r,n);});
139
+ assert.equal(ready(),true);assert.equal(ready(c=>{c.argument.expected='wrong';}),false);assert.equal(ready((c,r)=>r.delete(c.argument.screen.ready_selector)),false);
140
+ });
@@ -152,13 +152,15 @@ export function extractScreen({screen,policy,expected,freeze=false}) {
152
152
  }
153
153
  return {text,title:screen.label,marker,maskCount:masks.length};
154
154
  }
155
- export async function captureScreen(context,m,screen,binding,{signal,onDiagnostics}={}) {
155
+ // deadlineMs can only shorten the 60s capture deadline (tests use it); it never extends it.
156
+ export async function captureScreen(context,m,screen,binding,{signal,onDiagnostics,deadlineMs=60000}={}) {
157
+ const limit=Number.isFinite(deadlineMs)?Math.min(60000,Math.max(1000,deadlineMs)):60000;
156
158
  if(m.policy.version!==1&&!isLetterPolicy(m.policy,screen)&&!isLetterCapabilitiesPolicy(m.policy,screen))fail('invalid_policy');
157
159
  const page=await context.newPage();page.on('popup',popup=>popup.close().catch(()=>{}));let blocked=0,captureGuard='not_reached';const outerBlocks={navigation_scope:0,approved_read_redirect:0,non_read:0};const outerBlock=kind=>{outerBlocks[kind]=Math.min(64,outerBlocks[kind]+1);};
158
160
  const base={version:1,client_capture_id:crypto.randomUUID(),page_id:screen.id,expected_generation:m.generation,policy_sha256:m.policy_sha256,expected_previous_snapshot_id:screen.latest_snapshot?.id||null,session_epoch:binding.session_epoch,observed_at:new Date().toISOString(),requested_url:screen.url,final_url:null,outcome:'capture_failed',account:{label:m.account_label,assurance:'declared_only',marker_sha256:null},ready_matched:false,login_marker_matched:false,title:null,text:null,screenshot_png_base64:null};
159
- const capabilities=isLetterCapabilitiesPolicy(m.policy,screen),deadline=performance.now()+60000;
161
+ const capabilities=isLetterCapabilitiesPolicy(m.policy,screen),deadline=performance.now()+limit;
160
162
  const adapter=isLetterPolicy(m.policy,screen)||capabilities?new LetterReadAttempt(page,deadline,capabilities):null;
161
- const abort=()=>{if(capabilities)adapter.close();return page.close().catch(()=>{});};signal?.addEventListener('abort',abort,{once:true});const timer=setTimeout(abort,60000);
163
+ const abort=()=>{if(capabilities)adapter.close();return page.close().catch(()=>{});};signal?.addEventListener('abort',abort,{once:true});const timer=setTimeout(abort,limit);
162
164
  try{
163
165
  await page.route('**/*',async route=>{const req=route.request();const navigation=req.isNavigationRequest()&&req.frame()===page.mainFrame();const requested=req.url();const wanted=new URL(screen.url);wanted.hash='';if(adapter&&req.redirectedFrom()&&adapter.requests.has(req.redirectedFrom())){adapter.reject(req,'redirect','approved_read_redirect');blocked++;outerBlock('approved_read_redirect');await route.abort('blockedbyclient');}
164
166
  else if(navigation&&requested!==wanted.href&&requested!==screen.url){blocked++;outerBlock('navigation_scope');adapter?.recordDenial(req,'selected_page','navigation_scope');await route.abort('blockedbyclient');}
@@ -174,7 +176,13 @@ export async function captureScreen(context,m,screen,binding,{signal,onDiagnosti
174
176
  if(adapter&&screen.login_selector&&await page.evaluate(selector=>Array.from(document.querySelectorAll(selector)).some(node=>{const style=getComputedStyle(node),rect=node.getBoundingClientRect();return style.display!=='none'&&style.visibility!=='hidden'&&style.opacity!=='0'&&rect.width>0&&rect.height>0;}),screen.login_selector)){base.outcome='session_expired';base.login_marker_matched=true;return base;}
175
177
  if(adapter&&!await adapter.ready(signal)){if(signal?.aborted)fail('cancelled');captureGuard='readiness_refused';base.outcome='request_blocked';return base;}
176
178
  if(capabilities){
177
- const ready=await page.evaluate(letterPickerReady,{screen,expected:binding.markers[screen.id]});
179
+ let ready=await page.evaluate(letterPickerReady,{screen,expected:binding.markers[screen.id]});
180
+ // Completed reads can precede the page's own re-render that enables the opener.
181
+ // Re-run the same fail-closed check briefly within the original deadline; never click early.
182
+ for(const settle=Math.min(performance.now()+2000,deadline);ready.error==='not_ready'&&performance.now()<settle;){
183
+ await new Promise(resolve=>setTimeout(resolve,100));if(signal?.aborted)fail('cancelled');
184
+ ready=await page.evaluate(letterPickerReady,{screen,expected:binding.markers[screen.id]});
185
+ }
178
186
  if(ready.error){base.outcome=ready.error;base.login_marker_matched=ready.login===true;return base;}
179
187
  const remaining=()=>{const ms=Math.floor(deadline-performance.now());if(ms<=0)fail('timeout');return ms;};
180
188
  // The only interaction this adapter permits: open the observed provider picker once.
@@ -0,0 +1,69 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+ import {canonical,exactUrl,readMonitor,receipt,sendCapture,monitorRunner,productMonitorsCommand} from './product-monitors.mjs';
8
+ const f=JSON.parse(await fs.readFile(new URL('./fixtures/product-monitor-contract.json',import.meta.url),'utf8'));
9
+ const sha=x=>crypto.createHash('sha256').update(x).digest('hex');
10
+ const fresh=()=>structuredClone(f.monitor);
11
+ const identity={user:{id:'usr_'+'1'.repeat(32)},workspace:{id:'ws_'+'2'.repeat(32)}};
12
+ const fakeReceipt=(u,m=f.monitor.id)=>({...structuredClone(f.baseline_receipt),client_capture_id:u.client_capture_id,page_id:u.page_id,monitor_id:m,generation:u.expected_generation,manifest_sha256:sha(canonical(u)),observed_at:u.observed_at,outcome:u.outcome,snapshot:u.outcome==='captured'?{...f.baseline_receipt.snapshot,text_sha256:sha(u.text),text_bytes:Buffer.byteLength(u.text),screenshot_sha256:sha(Buffer.from(u.screenshot_png_base64,'base64')),screenshot_bytes:Buffer.from(u.screenshot_png_base64,'base64').length,policy_sha256:u.policy_sha256}:null,comparison:null});
13
+ async function temp(fn){const dir=await fs.mkdtemp(path.join(os.tmpdir(),'product-monitor-'));const prior=process.env.OPENCRAWL_CONFIG_DIR;process.env.OPENCRAWL_CONFIG_DIR=dir;try{await fn(dir);}finally{if(prior===undefined)delete process.env.OPENCRAWL_CONFIG_DIR;else process.env.OPENCRAWL_CONFIG_DIR=prior;await fs.rm(dir,{recursive:true,force:true});}}
14
+ test('actual backend fixture monitor and canonical manifest match; hashes/failure/scope changes refuse',()=>{
15
+ assert.equal(readMonitor(f.monitor).id,f.monitor.id);assert.equal(sha(canonical(f.capture_request)),f.baseline_receipt.manifest_sha256);assert.deepEqual(receipt(f.baseline_receipt,f.capture_request,f.monitor.id),f.baseline_receipt);
16
+ for(const change of [{manifest_sha256:'0'.repeat(64)},{monitor_id:'src_'+'0'.repeat(32)},{page_id:'ppg_'+'0'.repeat(32)},{generation:2},{outcome:'timeout'}])assert.throws(()=>receipt({...f.baseline_receipt,...change},f.capture_request,f.monitor.id));
17
+ });
18
+ test('fragments retained; action/credential/control/ambiguous URLs refused',()=>{assert.equal(exactUrl('https://product.example/app#overview').hash,'#overview');for(const u of ['https://u:p@product.example/','http://product.example/','https://product.example/logout','https://product.example/?action=x','https://product.example','https://product.example/\n'])assert.throws(()=>exactUrl(u));});
19
+ test('lost upload acknowledgement performs one exact GET and never repeats POST; confirmed local bytes are minimized',()=>temp(async dir=>{
20
+ const calls=[],u=structuredClone(f.capture_request);const client={request:async(r,o)=>{calls.push([r,o.method||'GET']);if(o.method==='POST')throw Error('lost');return fakeReceipt(u);}};
21
+ await sendCapture(client,f.monitor.id,u,dir);assert.deepEqual(calls.map(c=>c[1]),['POST','GET']);const saved=JSON.parse(await fs.readFile(path.join(dir,u.client_capture_id+'.json')));assert.equal(saved.state,'confirmed');assert.equal(saved.upload,undefined);assert.equal(saved.manifest_sha256,sha(canonical(u)));
22
+ await assert.rejects(sendCapture(client,f.monitor.id,u,dir),/capture_already_attempted/);assert.equal(calls.length,2);
23
+ }));
24
+ test('unconfirmed receipt remains durable, with no new POST on repeated use',()=>temp(async dir=>{let calls=0;const c={request:async()=>{calls++;throw Error('network');}};await assert.rejects(sendCapture(c,f.monitor.id,f.capture_request,dir),/upload_unconfirmed/);assert.equal(calls,2);const saved=JSON.parse(await fs.readFile(path.join(dir,f.capture_request.client_capture_id+'.json')));assert.equal(saved.state,'intended');await assert.rejects(sendCapture(c,f.monitor.id,f.capture_request,dir),/capture_already_attempted/);assert.equal(calls,2);}));
25
+ test('failure receipt has no image/text and confirmed mismatch does not become success',()=>temp(async dir=>{const u={...f.capture_request,outcome:'session_expired',final_url:null,text:null,title:null,screenshot_png_base64:null,ready_matched:false,login_marker_matched:true};const r=fakeReceipt(u);assert.equal(receipt(r,u,f.monitor.id).snapshot,null);await assert.rejects(sendCapture({request:async()=>({...r,manifest_sha256:'f'.repeat(64)})},f.monitor.id,u,dir),/receipt_mismatch/);}));
26
+ function fakeBrowser(m,{outcome,mutate,onCapture}={}){
27
+ let closes=0,captures=0;const png=Buffer.from(f.capture_request.screenshot_png_base64,'base64');
28
+ const pw={chromium:{launchPersistentContext:async()=>{const context={route:async()=>{},routeWebSocket:async()=>{},pages:()=>[],close:async()=>{closes++;},newPage:async()=>{let url='about:blank';return {on(){},close:async()=>{},route:async()=>{},routeWebSocket:async()=>{},mainFrame:()=>({}),goto:async u=>{url=u;return{status:()=>200};},url:()=>url,locator:()=>({waitFor:async()=>{}}),evaluate:async(fn,args)=>{if(args){captures++;onCapture?.(captures);return outcome?{error:outcome}:{text:'Observed workflow',title:'Overview',marker:'Example account'};}if(String(fn).includes('__opencrawlChanged'))return false;},screenshot:async()=>{mutate?.();return png;}};}};return context;}}};return {pw,get closes(){return closes;},get captures(){return captures;}};
29
+ }
30
+ async function loginFixture(dir,m,browser,client){const markers=path.join(dir,'markers.json');await fs.writeFile(markers,JSON.stringify({[m.pages[0].id]:'Example account'}));return monitorRunner('login',m,client,{profile:'test',markers},{playwright:browser.pw,waitForLogin:async()=>{}});}
31
+ function fakeClient(m,events=[]){return {baseUrl:'https://opencrawl.example',request:async(r,o={})=>{events.push([r,o.method||'GET']);if(r==='/api/auth/me')return identity;if(r.endsWith('/captures'))return fakeReceipt(o.body,m.id);return m;}};}
32
+ test('login verifies markers without upload; preview saves only private masked data; check uses exact same scope',()=>temp(async dir=>{const m=fresh();m.pages[0].latest_snapshot=null;const events=[],c=fakeClient(m,events),b=fakeBrowser(m);await loginFixture(dir,m,b,c);assert.equal(events.filter(x=>x[1]==='POST').length,0);const result=await monitorRunner('preview',m,c,{profile:'test',generation:1},{playwright:b.pw});assert.equal(result.captures.length,1);assert.equal((await fs.stat(result.captures[0].preview+'/masked.png')).mode&0o777,0o600);await monitorRunner('check',m,c,{profile:'test',generation:1},{playwright:b.pw});assert.equal(events.filter(x=>x[1]==='POST').length,1);assert.equal(b.closes,4);}));
33
+ test('watch runs serially at cadence and no catch-up; abort closes owned context',()=>temp(async dir=>{const m=fresh(),events=[],c=fakeClient(m,events),b=fakeBrowser(m);await loginFixture(dir,m,b,c);const a=new AbortController();let cycles=0;await monitorRunner('watch',m,c,{profile:'test',generation:1},{playwright:b.pw,signal:a.signal,sleep:async ms=>{assert.equal(ms,3600000);cycles++;if(cycles===2)a.abort();}});assert.equal(events.filter(x=>x[1]==='POST').length,2);assert.equal(b.closes,3);}));
34
+ test('changed identity, policy/generation and unavailable marker stop before upload',()=>temp(async dir=>{const m=fresh(),events=[],c=fakeClient(m,events),b=fakeBrowser(m);await loginFixture(dir,m,b,c);await assert.rejects(monitorRunner('check',{...m,generation:2},c,{profile:'test',generation:2},{playwright:b.pw}),/profile_binding_changed/);const other={...c,request:async(r,o)=>r==='/api/auth/me'?{...identity,user:{id:'usr_'+'3'.repeat(32)}}:c.request(r,o)};await assert.rejects(monitorRunner('check',m,other,{profile:'test',generation:1},{playwright:b.pw}),/profile_binding_changed/);const expired=fakeBrowser(m,{outcome:'session_expired'});await assert.rejects(monitorRunner('check',m,c,{profile:'test',generation:1},{playwright:expired.pw}),/session_expired/);assert.equal(events.filter(x=>x[1]==='POST').length,1);}));
35
+ test('authority revoked after browser capture prevents POST',()=>temp(async dir=>{const m=fresh(),events=[],c=fakeClient(m,events),b=fakeBrowser(m);await loginFixture(dir,m,b,c);const changed=fakeBrowser(m,{mutate:()=>{m.generation=2;}});const original=structuredClone(m);await assert.rejects(monitorRunner('check',original,c,{profile:'test',generation:1},{playwright:changed.pw}),/monitor_changed/);assert.equal(events.filter(x=>x[1]==='POST').length,0);}));
36
+ test('image read binds exact snapshot/hash and refuses overwrite',()=>temp(async dir=>{const png=Buffer.from(f.capture_request.screenshot_png_base64,'base64'),s=f.baseline_receipt.snapshot,events=[];const client={request:async(r,o)=>{events.push([r,o]);return r.endsWith('/image')?png:s;}};const args=['image',s.id],opts={revision:s.version,output:path.join(dir,'image.png')};await productMonitorsCommand(args,opts,client,{stdout:()=>{}});assert.deepEqual(await fs.readFile(opts.output),png);assert.equal(events[1][1].responseType,'png');await assert.rejects(productMonitorsCommand(args,opts,client,{stdout:()=>{}}),/output_exists/);}));
37
+ test('create uncertainty recovers by original ID; next invocation is GET only',()=>temp(async dir=>{const m=fresh(),input={client_create_id:crypto.randomUUID(),name:m.name,account_label:m.account_label,pages:m.pages.map(({id,last_attempt,latest_snapshot,...p})=>p),policy:m.policy,cadence_minutes:60};const spec=path.join(dir,'spec.json');await fs.writeFile(spec,JSON.stringify(input));const calls=[];const c={baseUrl:'https://opencrawl.example',request:async(r,o)=>{calls.push(o.method||'GET');if(r==='/api/auth/me')return identity;if(o.method==='POST')throw Error('lost');return m;}};await productMonitorsCommand(['create'],{spec},c,{stdout:()=>{}});await productMonitorsCommand(['create'],{spec},c,{stdout:()=>{}});assert.equal(calls.filter(x=>x==='POST').length,1);}));
38
+ test('monitor operation lock prevents overlap across profiles, and pending upload blocks restart',()=>temp(async dir=>{
39
+ const m=fresh(),c=fakeClient(m),b=fakeBrowser(m);await loginFixture(dir,m,b,c);
40
+ const scope={server:c.baseUrl,user_id:identity.user.id,workspace_id:identity.workspace.id,monitor:m.id};const operations=path.join(dir,'product-monitor-operations',sha(canonical(scope)));
41
+ await fs.writeFile(path.join(operations,'lock'),'fixture process');await assert.rejects(monitorRunner('check',m,c,{profile:'test',generation:1},{playwright:b.pw}),/profile_locked/);await fs.unlink(path.join(operations,'lock'));
42
+ const u={...f.capture_request,client_capture_id:crypto.randomUUID()};await fs.writeFile(path.join(operations,u.client_capture_id+'.json'),JSON.stringify({state:'intended',monitor:m.id,upload:u}));const captures=b.captures;
43
+ await assert.rejects(monitorRunner('watch',m,c,{profile:'test',generation:1},{playwright:b.pw}),/upload_unconfirmed/);assert.equal(b.captures,captures);
44
+ }));
45
+ test('local history cap refuses before any upload, and creation recovery compares complete settings',()=>temp(async dir=>{
46
+ for(let i=0;i<255;i++)await fs.writeFile(path.join(dir,`bounded-${i}`),'');let calls=0;
47
+ await assert.rejects(sendCapture({request:async()=>{calls++;}},f.monitor.id,f.capture_request,dir),/local_history_full/);assert.equal(calls,0);
48
+ const m=fresh(),input={client_create_id:crypto.randomUUID(),name:m.name,account_label:m.account_label,pages:m.pages.map(({id,last_attempt,latest_snapshot,...p})=>p),policy:m.policy,cadence_minutes:60};const spec=path.join(dir,'spec.json');await fs.writeFile(spec,JSON.stringify(input));
49
+ const wrong=structuredClone(m);wrong.pages[0].scope_selector='body';const c={baseUrl:'https://opencrawl.example',request:async r=>r==='/api/auth/me'?identity:wrong};
50
+ await assert.rejects(productMonitorsCommand(['create'],{spec},c,{stdout:()=>{}}),/receipt_mismatch/);
51
+ }));
52
+ test('CLI entrypoint routes product help and revision argument without global version confusion',async()=>{
53
+ const {promisify}=await import('node:util'),{execFile}=await import('node:child_process');const run=promisify(execFile),entry=new URL('../bin/opencrawl.mjs',import.meta.url);
54
+ const help=await run(process.execPath,[entry.pathname,'browser','monitor','--help']);assert.match(help.stdout,/--revision HASH/);assert.match(help.stdout,/preview\|check\|watch/);
55
+ await assert.rejects(run(process.execPath,[entry.pathname,'browser','monitor','compare','pcmp_bad','--revision','bad'],{env:{...process.env,OPENCRAWL_API_KEY:'fixture-token',OPENCRAWL_URL:'http://127.0.0.1:1'}}),e=>/invalid_id/.test(e.stderr)&&!e.stdout.includes('0.1.0'));
56
+ });
57
+
58
+ test('actual CLI browser login preview and check failures honor --json without a browser or network',async()=>{
59
+ const {promisify}=await import('node:util'),{execFile}=await import('node:child_process');const run=promisify(execFile),entry=new URL('../bin/opencrawl.mjs',import.meta.url);
60
+ for(const action of ['login','preview','check']){
61
+ const args=[entry.pathname,'--json','browser','monitor',action,'invalid'];
62
+ await assert.rejects(run(process.execPath,args,{env:{PATH:process.env.PATH,OPENCRAWL_API_KEY:'fixture-token',OPENCRAWL_URL:'http://127.0.0.1:1',OPENCRAWL_CONFIG_DIR:'/nonexistent-opencrawl-test-config'}}),e=>e.code===1&&e.stderr===''&&JSON.stringify(JSON.parse(e.stdout))===JSON.stringify({error:{code:'command_failed',message:'invalid_id'}}));
63
+ }
64
+ });
65
+ test('low-budget context preserves actual omitted rows without inventing their versions',async()=>{
66
+ const context=f.context.context.split('\n[')[0],value={context,context_bytes:Buffer.byteLength(context),budget:256,included:[],omitted:['before','after'].map(side=>({comparison_id:f.comparison.id,side,reason:'budget'})),image_content_included:false,assessment:'observed_interface_difference'};const out=[];
67
+ await productMonitorsCommand(['context',f.comparison.id],{revision:f.comparison.version,budget:256,json:true},{request:async()=>value},{stdout:s=>out.push(s)});assert.deepEqual(JSON.parse(out.join('')),value);
68
+ await assert.rejects(productMonitorsCommand(['context',f.comparison.id],{revision:f.comparison.version,budget:256},{request:async()=>({...value,omitted:[value.omitted[0],value.omitted[0]]})},{stdout:()=>{}}),/context_mismatch/);
69
+ });
@@ -0,0 +1,110 @@
1
+ import assert from 'node:assert/strict';
2
+ import {test} from 'node:test';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {createClient} from './client.mjs';
7
+ import {agentCommand,waitAgentRun} from './research-agent.mjs';
8
+
9
+ const id='ragent_'+'a'.repeat(32),runId='job_'+'b'.repeat(32),key='original-request-key-0001',stamp='2026-10-24T08:00:00.000Z';
10
+ const brief={customer:{name:'Synthetic',website:'https://example.com/',context:''},goal:'Read public product changes.',collection_ids:[],schedule_minutes:0};
11
+ const run={id:runId,agent_id:id,agent_revision:1,status:'queued',phase:'queued',created_at:stamp,started_at:null,finished_at:null,outcome:null,findings:[],gaps:[],usage:{},error:null};
12
+ const receipt={agent:{id,...brief,revision:1,status:'active',created_at:stamp,updated_at:stamp,latest_run:run},run,replayed:false};
13
+ const generic='Research request was not confirmed. Read the same agent/run or recover the original admission key; never start a duplicate blindly.';
14
+ const secret='PRIVATE_SERVER_TEXT\u001b[31m';
15
+ const refusal=status=>Object.assign(new Error(secret),{status,code:'private_server_code',cause:new Error(secret)});
16
+ async function execute(rest,options,value){
17
+ let stdout='',stderr='';const calls=[];
18
+ const client=value?.request?value:{request:async(route,args)=>{calls.push({route,args});if(value instanceof Error)throw value;return value;}};
19
+ const code=await agentCommand(rest,options,client,{stdout:v=>stdout+=v,stderr:v=>stderr+=v});return {code,stdout,stderr,calls};
20
+ }
21
+ function noLeak(result){assert(!JSON.stringify(result).includes('PRIVATE_SERVER_TEXT'));assert(!JSON.stringify(result).includes('private_server_code'));}
22
+
23
+ test('real CLI transport confirmed GET failures use finite guidance without server prose or another request',async()=>{
24
+ const folder=await fs.mkdtemp(path.join(os.tmpdir(),'research-read-guidance-')),priorDir=process.env.OPENCRAWL_CONFIG_DIR,priorFetch=globalThis.fetch;
25
+ process.env.OPENCRAWL_CONFIG_DIR=folder;
26
+ try{
27
+ const client=await createClient({server:'https://api.example',token:'synthetic-only-token'});
28
+ for(const [status,pattern] of [[401,/account, server and workspace/],[403,/administrator to confirm access/],[404,/Do not substitute a current webpage/],[409,/current revision before any write/]]){
29
+ const calls=[];globalThis.fetch=async(url,init)=>{calls.push({url,init});return new Response(JSON.stringify({detail:secret,code:'private_server_code'}),{status,headers:{'content-type':'application/json'}});};
30
+ const result=await execute(['agent-run',id,runId],{},client);
31
+ assert.equal(result.code,1);assert.equal(result.stdout,'');assert.match(result.stderr,pattern);assert.equal(calls.length,1);
32
+ assert.equal(calls[0].url,`https://api.example/api/research/agents/${id}/runs/${runId}`);assert.equal(calls[0].init.method,'GET');noLeak(result);
33
+ }
34
+ }finally{globalThis.fetch=priorFetch;if(priorDir===undefined)delete process.env.OPENCRAWL_CONFIG_DIR;else process.env.OPENCRAWL_CONFIG_DIR=priorDir;await fs.rm(folder,{recursive:true,force:true});}
35
+ });
36
+
37
+ test('all saved-read commands retain exit1 and finite JSON on confirmed denial',async()=>{
38
+ const commands=[['agent','list'],['agent','show',id],['agent','runs',id],['agent','schedule',id],['agent','repeats',id,runId],['agent','source',id,runId,'W1'],['agent','feedback',id,runId,'finding_one'],['agent-run',id,runId]];
39
+ for(const rest of commands)for(const status of [401,403,404,409]){
40
+ const result=await execute(rest,{json:true},refusal(status));const error=JSON.parse(result.stdout).error;
41
+ assert.equal(result.code,1);assert.equal(result.calls.length,1);assert.equal(result.calls[0].args.method,undefined);
42
+ assert.deepEqual(Object.keys(error).sort(),['code','message']);assert.equal(error.code,`http_${status}`);assert.notEqual(error.message,generic);assert.equal(result.stderr,'');noLeak(result);
43
+ }
44
+ });
45
+
46
+ test('unavailable original admission receipt keeps exact key and never authorizes replacement',async()=>{
47
+ for(const operation of ['create','run:'+id])for(const json of [false,true]){
48
+ const result=await execute(['agent','receipt'],{operation,'request-key':key,json},refusal(404));
49
+ assert.equal(result.code,1);assert.equal(result.calls.length,1);assert.equal(result.calls[0].args.idempotencyKey,key);assert.equal(result.calls[0].args.method,undefined);
50
+ assert.equal(result.calls[0].route,'/api/research/agents/receipts/by-key?operation='+encodeURIComponent(operation));
51
+ assert.match(json?JSON.parse(result.stdout).error.message:result.stderr,/missing receipt does not authorize a new admission/);
52
+ if(json)assert.equal(JSON.parse(result.stdout).error.request_key,key);else assert.match(result.stderr,new RegExp('Original request key: '+key));noLeak(result);
53
+ }
54
+ });
55
+
56
+ test('create rerun and other writes retain original uncertain guidance and request keys even on known HTTP errors',async()=>{
57
+ const folder=await fs.mkdtemp(path.join(os.tmpdir(),'research-write-unchanged-')),file=path.join(folder,'brief.json');
58
+ try{
59
+ await fs.writeFile(file,JSON.stringify(brief));
60
+ const commands=[
61
+ [['agent','create'],{file,'request-key':key},generic,'POST'],
62
+ [['agent','run',id],{revision:'1','request-key':key},generic,'POST'],
63
+ [['agent','pause',id],{revision:'1'},generic,'PATCH'],
64
+ [['agent','schedule',id,'daily'],{revision:'0'},'Schedule request was not confirmed. Read the same schedule and reconcile its setting and revision before another write.','PATCH'],
65
+ [['agent','feedback',id,runId,'finding_one','set'],{revision:'0',rating:'useful'},'Feedback was not confirmed. Read the same finding feedback and reconcile its revision before another write.','PUT'],
66
+ [['agent','feedback',id,runId,'finding_one','delete'],{revision:'0'},'Feedback was not confirmed. Read the same finding feedback and reconcile its revision before another write.','DELETE']
67
+ ];
68
+ for(const [rest,options,message,method] of commands)for(const status of [401,403,404,409,503,undefined]){
69
+ const result=await execute(rest,{...options,json:true},refusal(status)),error=JSON.parse(result.stdout).error;
70
+ assert.equal(result.code,1);assert.equal(result.calls.length,1);assert.equal(result.calls[0].args.method,method);assert.equal(error.message,message);
71
+ assert.equal(error.request_key,options['request-key']);assert.equal(result.calls[0].args.idempotencyKey,options['request-key']);noLeak(result);
72
+ }
73
+ }finally{await fs.rm(folder,{recursive:true,force:true});}
74
+ });
75
+
76
+ test('transport server and malformed read results remain uncertain and expose no unvalidated result',async()=>{
77
+ for(const value of [refusal(undefined),refusal('403'),refusal(500),refusal(503),{...run,id:'wrong',status:403,private:secret}]){
78
+ const result=await execute(['agent-run',id,runId],{json:true},value),error=JSON.parse(result.stdout).error;
79
+ assert.equal(result.code,1);assert.equal(result.calls.length,1);assert.equal(error.message,generic);noLeak(result);
80
+ }
81
+ });
82
+
83
+ test('wait read denial retains last validated run and stops after one GET with finite guidance',async()=>{
84
+ for(const status of [401,403,404,409]){
85
+ const calls=[];const result=await waitAgentRun({request:async(route,args)=>{calls.push({route,args});throw refusal(status);}},run);
86
+ assert.deepEqual(result.run,run);assert.equal(result.wait.outcome,'unavailable');assert.equal(result.wait.error.code,`http_${status}`);assert.equal(calls.length,1);
87
+ assert.equal(calls[0].route,`/api/research/agents/${id}/runs/${runId}`);assert.equal(calls[0].args.method,undefined);noLeak(result);
88
+ }
89
+ for(const json of [false,true]){
90
+ const calls=[];const result=await execute(['agent','run',id],{revision:'1','request-key':key,wait:true,json},{request:async(route,args)=>{calls.push({route,args});if(calls.length===1)return receipt;throw refusal(403);}});
91
+ assert.equal(result.code,1);assert.equal(calls.length,2);assert.deepEqual(calls.map(c=>c.args.method),['POST',undefined]);assert.equal(calls[0].args.idempotencyKey,key);
92
+ if(json){const saved=JSON.parse(result.stdout);assert.deepEqual(saved.run,run);assert.equal(saved.wait.outcome,'unavailable');assert.equal(saved.wait.error.code,'http_403');}
93
+ else{assert.match(result.stderr,/administrator to confirm access/);assert.match(result.stderr,new RegExp('Original request key: '+key));}
94
+ assert.match(result.stderr,/Waiting does not cancel server work/);noLeak(result);
95
+ }
96
+ });
97
+
98
+ test('wait deadline cancellation and uncertain responses keep prior outcomes without false confirmed guidance',async()=>{
99
+ for(const mode of ['abort','deadline','transport','malformed','server']){
100
+ const signal=new AbortController();let clock=0,calls=0;
101
+ const result=await waitAgentRun({request:async()=>{calls++;if(mode==='abort')signal.abort();if(mode==='deadline')clock=1000;if(mode==='malformed')return {...run,id:'wrong'};throw refusal(mode==='transport'?undefined:mode==='server'?503:401);}},run,{timeout:1,signal:signal.signal,now:()=>clock,sleep:async ms=>{clock+=ms;}});
102
+ // A 503 (a restarting server) keeps the wait going until its deadline; it never confirms anything.
103
+ assert.equal(calls,1);assert.deepEqual(result.run,run);assert.deepEqual(result.wait,{outcome:mode==='abort'?'interrupted':mode==='deadline'||mode==='server'?'timeout':'unavailable'});noLeak(result);
104
+ }
105
+ });
106
+ test('a refused schedule change names the server reason, such as a run still in progress',async()=>{
107
+ const refused=Object.assign(new Error('OpenCrawl 409'),{status:409,detail:'Wait for the current run before enabling its schedule.'});
108
+ const result=await execute(['agent','schedule',id,'daily'],{revision:'0',json:true},refused);
109
+ assert.equal(JSON.parse(result.stdout).error.message,'Schedule request was not confirmed. Read the same schedule and reconcile its setting and revision before another write. Server: Wait for the current run before enabling its schedule.');
110
+ });
@@ -0,0 +1,28 @@
1
+ import assert from 'node:assert/strict';
2
+ import {test} from 'node:test';
3
+ import {agentRequest,agentCommand,validateAgentFeedback} from './research-agent.mjs';
4
+ const aid='ragent_'+'a'.repeat(32),rid='job_'+'b'.repeat(32),fid='finding_example';
5
+ const root=`/api/research/agents/${aid}/runs/${rid}/findings/${fid}/feedback`,args=['agent','feedback',aid,rid,fid];
6
+ const receipt=patch=>({agent_id:aid,run_id:rid,finding_id:fid,feedback:{version:1,revision:0,state:'absent',rating:null,note:null,updated_at:null,...patch}});
7
+ test('CLI feedback maps explicit commands to one scoped CAS request and preserves JSON',async()=>{
8
+ for(const [action,options,expected,method] of [
9
+ ['show',{},receipt(),'GET'],
10
+ ['set',{revision:'0',rating:'incorrect',note:' Customer correction 😀 '},receipt({revision:1,state:'active',rating:'incorrect',note:' Customer correction 😀 ',updated_at:'2026-10-24T00:00:00Z'}),'PUT'],
11
+ ['delete',{revision:'1'},receipt({revision:2,state:'deleted',updated_at:'2026-10-24T00:00:00Z'}),'DELETE']]){
12
+ let calls=0,out='',err='';const code=await agentCommand([...args,action],{...options,json:true},{request:async(path,init)=>{
13
+ calls++;assert.equal(path,root+(action==='delete'?'?expected_revision=1':''));assert.equal(init.method??'GET',method);
14
+ if(action==='set')assert.deepEqual(init.body,{expected_revision:0,rating:'incorrect',note:options.note});return expected;
15
+ }},{stdout:v=>out+=v,stderr:v=>err+=v});
16
+ assert.equal(code,0);assert.equal(calls,1);assert.deepEqual(JSON.parse(out),expected);assert.equal(err,'');
17
+ }
18
+ });
19
+ test('CLI rejects invalid ratings/notes/revisions and mismatched saved identity',()=>{
20
+ for(const options of [{revision:'0',rating:'proven'},{revision:'-1',rating:'useful'},{revision:'0',rating:'useful',note:'x'.repeat(1001)},{revision:'0',rating:'useful',note:'\ud800'},{revision:'0',rating:'useful',note:'bad\u0000note'}])assert.throws(()=>agentRequest([...args,'set'],options));
21
+ assert.throws(()=>agentRequest([...args,'delete'],{}));
22
+ assert.throws(()=>validateAgentFeedback({...receipt(),finding_id:'other'},aid,rid,fid));
23
+ assert.throws(()=>validateAgentFeedback(receipt({state:'deleted'}),aid,rid,fid));
24
+ });
25
+ test('CLI feedback uncertain write returns one error without retry or success text',async()=>{
26
+ let calls=0,out='',err='';const code=await agentCommand([...args,'set'],{revision:'0',rating:'useful'},{request:async()=>{calls++;throw Object.assign(Error('conflict'),{status:409})}},{stdout:v=>out+=v,stderr:v=>err+=v});
27
+ assert.equal(code,1);assert.equal(calls,1);assert.equal(out,'');assert.match(err,/Read the same finding feedback/);
28
+ });