@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,171 @@
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 http from 'node:http';
7
+ import { spawn } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { validateCaptureReceipt } from './capture.mjs';
10
+
11
+ const SCRIPT=fileURLToPath(new URL('../bin/opencrawl.mjs',import.meta.url));
12
+ const identity={user:{id:'usr_'+'a'.repeat(32)},workspace:{id:'ws_'+'b'.repeat(32)}};
13
+ const doc='doc_'+'c'.repeat(32),key='oc_synthetic_capture';
14
+ function run(args,env,cwd,input='') {
15
+ return new Promise((resolve,reject)=>{
16
+ const child=spawn(process.execPath,[SCRIPT,...args],{env,cwd,stdio:['pipe','pipe','pipe']});let stdout='',stderr='';
17
+ const timer=setTimeout(()=>child.kill('SIGKILL'),15000);
18
+ child.stdout.on('data',chunk=>stdout+=chunk);child.stderr.on('data',chunk=>stderr+=chunk);
19
+ child.on('error',reject);child.on('close',code=>{clearTimeout(timer);resolve({code,stdout,stderr});});child.stdin.end(input);
20
+ });
21
+ }
22
+ async function fixture(t,{provider='codex',capture=true,environment=false}={}) {
23
+ const dir=await fs.mkdtemp(path.join(os.tmpdir(),'oc-capture-')),config=path.join(dir,'config'),project=path.join(dir,'project');await fs.mkdir(project);
24
+ const env={...process.env};for(const name of Object.keys(env))if(name.startsWith('OPENCRAWL_'))delete env[name];env.OPENCRAWL_CONFIG_DIR=config;
25
+ const calls=[],sessions=new Map();let behavior='ok',who=identity;
26
+ const server=http.createServer(async(req,res)=>{
27
+ let raw='';for await(const chunk of req)raw+=chunk;const body=raw?JSON.parse(raw):null;calls.push({route:req.url,body});
28
+ const reply=(status,value)=>{res.writeHead(status,{'Content-Type':'application/json'});res.end(JSON.stringify(value));};
29
+ if(req.headers.authorization!=='Bearer '+key)return reply(401,{detail:'PRIVATE_PROVIDER_ERROR'});
30
+ if(req.url==='/api/auth/me')return reply(200,who);
31
+ if(req.url==='/api/v1/context')return reply(200,{context:'[S1] Earlier synthetic evidence'});
32
+ if(req.url==='/mcp')return reply(200,{jsonrpc:'2.0',id:body.id,result:{tools:[]}});
33
+ if(req.url==='/api/v1/sessions/events') {
34
+ if(['401','403','409','410','413','422','429','500'].includes(behavior))return reply(Number(behavior),{detail:{code:'synthetic',message:'PRIVATE_PROVIDER_ERROR'}});
35
+ let rows=sessions.get(body.session_id)||new Map(),accepted=0,replayed=0;
36
+ for(const event of body.events){const prior=rows.get(event.id);if(prior){if(JSON.stringify(prior)!==JSON.stringify(event))return reply(409,{detail:'changed'});replayed++;}else{rows.set(event.id,event);accepted++;}}
37
+ sessions.set(body.session_id,rows);
38
+ if(behavior==='lost'){req.socket.destroy();return;}
39
+ return reply(200,{document_id:doc,lexical_ready:true,semantic_ready:false,semantic:{status:'pending',indexed_chunks:0,total_chunks:1},event_count:rows.size,accepted_events:accepted,replayed_events:replayed});
40
+ }
41
+ if(req.url.startsWith('/api/v1/sessions/events/status?')) {
42
+ if(behavior==='slow_status')await new Promise(resolve=>setTimeout(resolve,1000));
43
+ if(behavior==='500')return reply(500,{detail:'PRIVATE_PROVIDER_ERROR'});
44
+ if(behavior==='410')return reply(410,{detail:{code:'session_deleted',message:'Deleted'}});
45
+ const rows=sessions.get(new URL(req.url,'http://localhost').searchParams.get('session_id'));
46
+ return rows?reply(200,{document_id:doc,lexical_ready:true,semantic_ready:true,semantic:{status:'ready',indexed_chunks:1,total_chunks:1},event_count:rows.size}):reply(404,{detail:'Unknown'});
47
+ }
48
+ reply(404,{});
49
+ });await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const url='http://127.0.0.1:'+server.address().port;
50
+ t.after(async()=>{await new Promise(resolve=>server.close(resolve));await fs.rm(dir,{recursive:true,force:true});});
51
+ let bound;
52
+ if(environment){env.OPENCRAWL_TEST_CAPTURE_KEY=key;bound=['--credential-env','OPENCRAWL_TEST_CAPTURE_KEY','--server',url];}
53
+ else {assert.equal((await run(['login','--server',url],env,project,key+'\n')).code,0);bound=[];}
54
+ const installed=await run(['install',provider,...(capture?['--capture']:[]),...bound],env,project);assert.equal(installed.code,0,installed.stderr);
55
+ const settingsFile=path.join(project,provider==='codex'?'.codex/hooks.json':'.claude/settings.local.json');
56
+ let hooks={};try{hooks=JSON.parse(await fs.readFile(settingsFile,'utf8')).hooks||{};}catch(error){if(error.code!=='ENOENT')throw error;}
57
+ const entry=hooks.UserPromptSubmit?.flatMap(group=>group.hooks).find(hook=>hook.command.includes('opencrawl-managed-capture'));
58
+ const configValue=environment?null:JSON.parse(await fs.readFile(path.join(config,'config.json')));
59
+ const args=environment?[...bound,'--expect-user',identity.user.id,'--expect-workspace',identity.workspace.id]:['--account',configValue.activeProfile,'--server',url];
60
+ const command=(name,...rest)=>run([name,...rest,...args],env,project);
61
+ const hook=(event,extra={},overrides={})=>run(['capture-hook','--provider',provider,'--project',project,...args],{...env,...overrides},project,JSON.stringify({session_id:'synthetic-session',turn_id:'turn-1',cwd:project,hook_event_name:event,...extra}));
62
+ const readState=async()=>{const file=(await fs.readdir(path.join(config,'capture'))).find(value=>value.endsWith('.json'));const location=path.join(config,'capture',file);return{file:location,value:JSON.parse(await fs.readFile(location))};};
63
+ return{dir,config,project,env,url,args,calls,sessions,hooks,entry,installed,command,hook,readState,setBehavior:value=>behavior=value,setIdentity:value=>who=value};
64
+ }
65
+
66
+ test('retrieval-only installs do not create capture hooks or spool',async t=>{
67
+ const f=await fixture(t,{capture:false});assert.equal(f.entry,undefined);await assert.rejects(fs.stat(path.join(f.config,'capture')),{code:'ENOENT'});
68
+ });
69
+ for(const provider of ['claude-code','codex'])test(`${provider} capture install is private, pinned, idempotent and preserves other hooks`,async t=>{
70
+ const f=await fixture(t,{provider});assert.ok(f.entry);assert.equal(f.entry.timeout,10);assert.ok(f.hooks.Stop);
71
+ assert.ok(f.entry.command.includes('--account')&&f.entry.command.includes('--project')&&!f.entry.command.includes(key));
72
+ const file=path.join(f.project,provider==='codex'?'.codex/hooks.json':'.claude/settings.local.json');const settings=JSON.parse(await fs.readFile(file));settings.hooks.Stop.unshift({hooks:[{type:'command',command:'echo unrelated'}]});await fs.writeFile(file,JSON.stringify(settings));
73
+ assert.equal((await run(['install',provider,'--capture'],f.env,f.project)).code,0);
74
+ assert.equal((await run(['install',provider,'--capture'],f.env,f.project)).code,0);
75
+ const current=JSON.parse(await fs.readFile(file));assert.equal(current.hooks.Stop.flatMap(group=>group.hooks).length,2);assert.equal(current.hooks.Stop[0].hooks[0].command,'echo unrelated');
76
+ const state=await f.readState();assert.equal((await fs.stat(state.file)).mode&0o777,0o600);assert.equal(state.value.sessions.length,0);
77
+ });
78
+ test('prompt retrieves old evidence first then captures; Stop is quiet and deduplicated',async t=>{
79
+ const f=await fixture(t);f.calls.length=0;
80
+ const prompt=await f.hook('UserPromptSubmit',{prompt:'The synthetic user asks a question.'});assert.equal(prompt.code,0);assert.match(JSON.parse(prompt.stdout).hookSpecificOutput.additionalContext,/Earlier synthetic/);
81
+ assert.ok(f.calls.findIndex(call=>call.route==='/api/v1/context')<f.calls.findIndex(call=>call.route==='/api/v1/sessions/events'));
82
+ assert.deepEqual(JSON.parse((await f.hook('Stop',{last_assistant_message:'Synthetic final answer'})).stdout),{});
83
+ await f.hook('Stop',{last_assistant_message:'Synthetic final answer'});await f.hook('UserPromptSubmit',{prompt:'The synthetic user asks a question.'});
84
+ assert.equal([...f.sessions.values()][0].size,2);const state=(await f.readState()).value;
85
+ assert.equal(state.sessions[0].events.length,2);assert.ok(state.sessions[0].events.every(event=>event.content===undefined));
86
+ assert.equal(state.sessions[0].receipt.semantic_ready,false);
87
+ const result=await f.command('capture','status','--provider','codex','--json');const observed=JSON.parse(result.stdout);assert.equal(observed.sessions[0].last_acknowledgement.semantic_ready,true);assert.ok(!result.stdout.includes('Synthetic final answer'));
88
+ });
89
+ test('new Codex turns append stable distinct events even when prompt text repeats',async t=>{
90
+ const f=await fixture(t);for(const id of ['turn-1','turn-2']){await f.hook('UserPromptSubmit',{turn_id:id,prompt:'Same exact question'});await f.hook('Stop',{turn_id:id,last_assistant_message:'Same answer'});}
91
+ const rows=[...f.sessions.values()][0];assert.equal(rows.size,4);assert.deepEqual([...rows.values()].map(event=>event.sequence),[0,1,2,3]);
92
+ });
93
+ test('Claude pairs new prompts with Stop without reading transcript paths',async t=>{
94
+ const f=await fixture(t,{provider:'claude-code'});for(let index=0;index<2;index++){await f.hook('UserPromptSubmit',{turn_id:undefined,prompt:'Repeat prompt',transcript_path:'/does/not/exist'});await f.hook('Stop',{turn_id:undefined,last_assistant_message:'Repeat answer',transcript_path:'/does/not/exist'});await f.hook('Stop',{turn_id:undefined,last_assistant_message:'Repeat answer'});}
95
+ assert.equal([...f.sessions.values()][0].size,4);
96
+ });
97
+ test('different project cwd is rejected before retrieval or event publication',async t=>{
98
+ const f=await fixture(t);const other=path.join(f.dir,'other');await fs.mkdir(other);f.calls.length=0;
99
+ const result=await f.hook('UserPromptSubmit',{cwd:other,prompt:'PRIVATE_OTHER_PROJECT'});assert.equal(result.code,0);assert.deepEqual(JSON.parse(result.stdout),{});assert.equal(f.calls.length,0);assert.ok(!JSON.stringify((await f.readState()).value).includes('PRIVATE_OTHER_PROJECT'));
100
+ });
101
+ test('lost acknowledgement remains queued and explicit flush replays exact events once',async t=>{
102
+ const f=await fixture(t);f.setBehavior('lost');await f.hook('UserPromptSubmit',{prompt:'Durable synthetic event'});let state=(await f.readState()).value;
103
+ assert.equal(state.sessions[0].events[0].state,'queued');assert.equal(state.sessions[0].receipt,undefined);assert.equal([...f.sessions.values()][0].size,1);
104
+ f.setBehavior('ok');const result=await f.command('capture','flush','--provider','codex','--json');assert.equal(result.code,0,result.stderr);assert.equal([...f.sessions.values()][0].size,1);state=(await f.readState()).value;assert.equal(state.sessions[0].events[0].state,'acknowledged');
105
+ const posts=f.calls.filter(call=>call.route==='/api/v1/sessions/events');assert.deepEqual(posts[0].body,posts[1].body);
106
+ });
107
+ for(const status of ['409','410'])test(`${status} permanently blocks this session without recreating its ID`,async t=>{
108
+ const f=await fixture(t);f.setBehavior(status);await f.hook('UserPromptSubmit',{prompt:'Synthetic blocked event'});const before=f.calls.filter(call=>call.route==='/api/v1/sessions/events').length;
109
+ f.setBehavior('ok');const result=await f.command('capture','flush','--provider','codex','--json');assert.equal(result.code,1);await f.hook('Stop',{last_assistant_message:'No resurrection'});assert.equal(f.calls.filter(call=>call.route==='/api/v1/sessions/events').length,before);assert.ok(!result.stdout.includes('PRIVATE_PROVIDER_ERROR'));
110
+ });
111
+ test('capture pause prevents collection and resume starts only new hook messages',async t=>{
112
+ const f=await fixture(t);assert.equal((await f.command('capture','pause','--provider','codex')).code,0);await f.hook('UserPromptSubmit',{prompt:'Do not collect'});assert.equal((await f.readState()).value.sessions.length,0);
113
+ await f.command('capture','resume','--provider','codex');await f.hook('UserPromptSubmit',{prompt:'Collect this future message'});assert.equal([...f.sessions.values()][0].size,1);
114
+ });
115
+ test('deleted session remains permanently blocked through identity failure and recovery',async t=>{
116
+ const f=await fixture(t,{environment:true});f.setBehavior('410');await f.hook('UserPromptSubmit',{prompt:'Deleted synthetic session'});
117
+ const before=f.calls.filter(call=>call.route==='/api/v1/sessions/events').length;
118
+ f.setIdentity({...identity,user:{id:'usr_'+'d'.repeat(32)}});await f.hook('Stop',{last_assistant_message:'Must never be collected'});
119
+ assert.equal((await f.readState()).value.sessions[0].issue,'session_deleted');
120
+ f.setIdentity(identity);f.setBehavior('ok');await f.hook('UserPromptSubmit',{turn_id:'turn-2',prompt:'Must not restart deleted capture'});
121
+ const result=await f.command('capture','flush','--provider','codex','--json');assert.equal(result.code,1);
122
+ assert.equal(f.calls.filter(call=>call.route==='/api/v1/sessions/events').length,before);
123
+ const session=(await f.readState()).value.sessions[0];assert.equal(session.issue,'session_deleted');assert.equal(session.events.length,1);
124
+ });
125
+ test('environment install saves no key and rejects a changed account before sending content',async t=>{
126
+ const f=await fixture(t,{environment:true});assert.ok(f.entry.command.includes('--expect-user'));assert.ok(!f.entry.command.includes(key));await assert.rejects(fs.stat(path.join(f.config,'config.json')),{code:'ENOENT'});
127
+ for(const argument of ['capture','status','--provider','codex','--project',f.project,'--credential-env','OPENCRAWL_TEST_CAPTURE_KEY','--server',f.url,'--expect-user',identity.user.id,'--expect-workspace',identity.workspace.id])assert.ok(f.installed.stdout.includes(argument));
128
+ assert.ok(!f.installed.stdout.includes(key));
129
+ await f.hook('UserPromptSubmit',{prompt:'Initial synthetic event'});const before=f.calls.filter(call=>call.route==='/api/v1/sessions/events'||call.route==='/api/v1/context').length;
130
+ f.setIdentity({...identity,user:{id:'usr_'+'d'.repeat(32)}});await f.hook('Stop',{last_assistant_message:'Must stay locally queued'});assert.equal(f.calls.filter(call=>call.route==='/api/v1/sessions/events'||call.route==='/api/v1/context').length,before);
131
+ const result=await f.command('capture','flush','--provider','codex','--json');assert.equal(result.code,1);assert.ok(!result.stdout.includes(key));
132
+ });
133
+ test('missing environment key does not fall back and hooks remain nonblocking',async t=>{
134
+ const f=await fixture(t,{environment:true});f.calls.length=0;const result=await f.hook('UserPromptSubmit',{prompt:'No key'}, {OPENCRAWL_TEST_CAPTURE_KEY:''});assert.equal(result.code,0);assert.deepEqual(JSON.parse(result.stdout),{});assert.equal(f.calls.length,0);
135
+ });
136
+ test('environment-bound retrieval-only hook and MCP reject changed workspace before private queries',async t=>{
137
+ const f=await fixture(t,{environment:true,capture:false,provider:'claude-code'});
138
+ const prompt=JSON.stringify({prompt:'Private synthetic retrieval query'}),rpc='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"opencrawl_search","arguments":{"query":"Private synthetic query"}}}\n';
139
+ assert.match((await f.command('hook')).stdout,/^$/);
140
+ assert.equal((await run(['mcp',...f.args],f.env,f.project,rpc)).code,0);
141
+ assert.ok((await run(['hook',...f.args],f.env,f.project,prompt)).stdout.includes('Earlier synthetic'));
142
+ f.setIdentity({...identity,workspace:{id:'ws_'+'e'.repeat(32)}});f.calls.length=0;
143
+ const hooked=await run(['hook',...f.args],f.env,f.project,prompt);assert.equal(hooked.code,0);assert.equal(hooked.stdout,'');
144
+ assert.equal((await run(['mcp',...f.args],f.env,f.project,rpc)).code,1);
145
+ assert.ok(f.calls.every(call=>call.route==='/api/auth/me'));
146
+ });
147
+ test('oversized messages and orphan Stop cannot create partial conversations',async t=>{
148
+ const f=await fixture(t);await f.hook('Stop',{last_assistant_message:'Orphan'});assert.equal(f.sessions.size,0);
149
+ const result=await f.hook('UserPromptSubmit',{prompt:'x'.repeat(65537)});assert.equal(result.code,0);assert.equal(f.sessions.size,0);assert.equal((await f.readState()).value.sessions.length,0);
150
+ });
151
+ test('invalid receipt does not establish indexing',()=>{
152
+ assert.throws(()=>validateCaptureReceipt({document_id:doc,lexical_ready:true,semantic_ready:false,semantic:{status:'pending',indexed_chunks:0,total_chunks:1},event_count:1,accepted_events:0,replayed_events:0},1));
153
+ });
154
+ test('status network wait releases capture lock for new hook events',async t=>{
155
+ const f=await fixture(t);await f.hook('UserPromptSubmit',{prompt:'First message'});f.setBehavior('slow_status');
156
+ const pending=f.command('capture','status','--provider','codex','--json');
157
+ for(let index=0;index<100&&!f.calls.some(call=>call.route.includes('/events/status?'));index++)await new Promise(resolve=>setTimeout(resolve,10));
158
+ const reply=await f.hook('Stop',{last_assistant_message:'Captured during a status request'});assert.equal(reply.stderr,'');assert.equal([...f.sessions.values()][0].size,2);assert.equal((await pending).code,0);
159
+ });
160
+ test('failed current verification cannot relabel historical acknowledgements as currently ready',async t=>{
161
+ const f=await fixture(t);await f.hook('UserPromptSubmit',{prompt:'Earlier acknowledged message'});f.setBehavior('500');
162
+ const result=await f.command('capture','status','--provider','codex','--json'),session=JSON.parse(result.stdout).sessions[0];
163
+ assert.equal(result.code,1);assert.equal(session.state,'verification_unavailable');assert.equal(session.last_acknowledgement.lexical_ready,true);assert.equal(session.current_verification.state,'unavailable');assert.ok(!result.stdout.includes('PRIVATE_PROVIDER_ERROR'));
164
+ });
165
+ test('encoded oversized control characters cannot leave an unsendable pending event',async t=>{
166
+ const f=await fixture(t);await f.hook('UserPromptSubmit',{prompt:'\u0001'.repeat(50000)});assert.equal(f.sessions.size,0);assert.equal((await f.readState()).value.sessions.length,0);
167
+ });
168
+ test('changed repeated event is blocked without silently replacing captured content',async t=>{
169
+ const f=await fixture(t);await f.hook('UserPromptSubmit',{prompt:'Original event'});await f.hook('UserPromptSubmit',{prompt:'Changed payload for same turn'});
170
+ const state=(await f.readState()).value;assert.equal(state.sessions[0].issue,'event_conflict');assert.equal([...f.sessions.values()][0].size,1);
171
+ });
package/src/client.mjs CHANGED
@@ -131,7 +131,12 @@ export async function createClient(overrides = {}) {
131
131
  try {
132
132
  response = await fetch(destination.href, { method, headers, body: body === undefined ? undefined : multipart ? body : JSON.stringify(body),
133
133
  signal: controller.signal, redirect: 'error' });
134
- } catch (error) { throw new Error(error.name === 'TimeoutError' ? 'OpenCrawl request timed out.' : 'Cannot reach OpenCrawl. Check your server URL and connection.'); }
134
+ } catch (error) {
135
+ const failure=new Error(error.name === 'TimeoutError' ? 'OpenCrawl request timed out.' : 'Cannot reach OpenCrawl. Check your server URL and connection.');
136
+ // A user's own interruption is final; a lost connection or timeout may succeed on another try.
137
+ if(!signal?.aborted)failure.transient=true;
138
+ throw failure;
139
+ }
135
140
  if(responseType==='png'&&response.ok){
136
141
  const cap=2*1024*1024;if(response.headers.get('content-type')?.split(';')[0]?.trim()!=='image/png'||Number(response.headers.get('content-length'))>cap){await response.body?.cancel();throw new Error('Invalid bounded PNG response.');}
137
142
  const reader=response.body?.getReader();if(!reader)throw new Error('Missing PNG response.');const bytes=Buffer.alloc(cap);let length=0;const cancel=()=>{void reader.cancel().catch(()=>{});};if(controller.signal.aborted)cancel();else controller.signal.addEventListener('abort',cancel,{once:true});
@@ -154,10 +159,13 @@ export async function createClient(overrides = {}) {
154
159
  text=bytes.subarray(0,length).toString('utf8');
155
160
  } else text = await response.text();
156
161
  let value;
157
- try { value = text ? JSON.parse(text) : null; } catch { throw new Error(`Server returned an unexpected response (${response.status}).`); }
162
+ try { value = text ? JSON.parse(text) : null; } catch {
163
+ // A proxy's error page (for example a 502 while the server restarts) is not JSON.
164
+ const failure=new Error(`Server returned an unexpected response (${response.status}).`);if(!response.ok)failure.status=response.status;throw failure;
165
+ }
158
166
  if (!response.ok) {
159
167
  const detail = typeof value?.detail === 'string' ? value.detail : typeof value?.error === 'string' ? value.error : response.statusText;
160
- const error=new Error(`OpenCrawl ${response.status}: ${detail}`);error.status=response.status;
168
+ const error=new Error(`OpenCrawl ${response.status}: ${detail}`);error.status=response.status;error.detail=String(detail||'').slice(0,300);
161
169
  if (typeof value?.code === 'string' && /^[a-z][a-z0-9_]{0,63}$/.test(value.code)) error.code=value.code;
162
170
  throw error;
163
171
  }
@@ -167,9 +175,9 @@ export async function createClient(overrides = {}) {
167
175
  }
168
176
  export async function search(query, options = {}) {
169
177
  const client = await createClient(options);
170
- return client.request('/api/v1/search', { method: 'POST', body: { query, limit: options.limit || 8, ...(options.source_ids === undefined ? {} : {source_ids: options.source_ids}) } });
178
+ return client.request('/api/v1/search', { method: 'POST', body: { query, limit: options.limit || 8, ...(options.source_ids === undefined ? {} : {source_ids: options.source_ids}), ...(options.customer ? {customer: options.customer} : {}) } });
171
179
  }
172
180
  export async function context(query, options = {}) {
173
181
  const client = await createClient(options);
174
- return client.request('/api/v1/context', { method: 'POST', body: { query, limit: options.limit || 8, budget: options.budget || 2000, ...(options.source_ids === undefined ? {} : {source_ids: options.source_ids}) } });
182
+ return client.request('/api/v1/context', { method: 'POST', body: { query, limit: options.limit || 8, budget: options.budget || 2000, ...(options.source_ids === undefined ? {} : {source_ids: options.source_ids}), ...(options.customer ? {customer: options.customer} : {}) } });
175
183
  }
@@ -0,0 +1,34 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { validateBaseUrl, search, context } from './client.mjs';
4
+ test('credentials are never accepted inside the server URL', () => {
5
+ assert.throws(() => validateBaseUrl('https://secret@example.com'));
6
+ assert.throws(() => validateBaseUrl('https://example.com?key=secret'));
7
+ });
8
+ test('exported retrieval helpers distinguish omitted scope from an explicit empty selection', async t => {
9
+ const bodies = [];
10
+ t.mock.method(globalThis, 'fetch', async (_url, options) => {
11
+ bodies.push(JSON.parse(options.body));
12
+ return new Response('{}', {headers: {'content-type': 'application/json'}});
13
+ });
14
+ for (const invoke of [search, context]) {
15
+ const options = {server: 'https://example.test', token: 'oc_synthetic'};
16
+ await invoke('question', options);
17
+ assert.equal(Object.hasOwn(bodies.at(-1), 'source_ids'), false);
18
+ await invoke('question', {...options, source_ids: []});
19
+ assert.deepEqual(bodies.at(-1).source_ids, []);
20
+ await invoke('question', {...options, source_ids: ['src_alpha']});
21
+ assert.deepEqual(bodies.at(-1).source_ids, ['src_alpha']);
22
+ }
23
+ });
24
+ test('remote hosts require transport encryption', () => {
25
+ assert.throws(() => validateBaseUrl('http://example.com'));
26
+ assert.equal(validateBaseUrl('http://127.0.0.1:8000/'), 'http://127.0.0.1:8000');
27
+ assert.equal(validateBaseUrl('https://opencrawl.example/'), 'https://opencrawl.example');
28
+ });
29
+ test('PNG transport is bounded, authenticated and cancellation-aware',async t=>{
30
+ const {createClient}=await import('./client.mjs'),fs=await import('node:fs/promises');const f=JSON.parse(await fs.readFile(new URL('./fixtures/product-monitor-contract.json',import.meta.url)));const png=Buffer.from(f.capture_request.screenshot_png_base64,'base64'),calls=[];
31
+ t.mock.method(globalThis,'fetch',async(url,o)=>{calls.push([url,o]);return new Response(png,{headers:{'content-type':'image/png'}});});const c=await createClient({server:'https://api.example',token:'synthetic-token'});assert.deepEqual(await c.request('/api/v1/product-monitors/snapshots/fixture/image',{responseType:'png'}),png);assert.equal(calls[0][1].headers.Accept,'image/png');assert.equal(calls[0][1].redirect,'error');assert.equal(calls[0][1].headers.Authorization,'Bearer synthetic-token');
32
+ t.mock.method(globalThis,'fetch',async()=>new Response(new Uint8Array(2097153),{headers:{'content-type':'image/png'}}));await assert.rejects(c.request('/api/v1/product-monitors/snapshots/fixture/image',{responseType:'png'}),/limit/);
33
+ let cancelled=false;const abort=new AbortController();t.mock.method(globalThis,'fetch',async()=>new Response(new ReadableStream({start(){setTimeout(()=>abort.abort(),10);},cancel(){cancelled=true}}),{headers:{'content-type':'image/png'}}));await assert.rejects(c.request('/api/v1/product-monitors/snapshots/fixture/image',{responseType:'png',signal:abort.signal}),/cancelled/);assert.equal(cancelled,true);
34
+ });
@@ -0,0 +1,131 @@
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 {connectCommand,connectRequest} from './connect.mjs';
7
+
8
+ const id='src_'+'a'.repeat(32),user='usr_'+'b'.repeat(32),workspace='ws_'+'c'.repeat(32),nonce='n'.repeat(43);
9
+ const job=()=>({id:'job_'+'d'.repeat(32),status:'queued',kind:'sync',source_id:id});
10
+ const failure=(status)=>Object.assign(new Error('sensitive provider payload ak_do_not_echo'),status?{status}:{});
11
+ async function fixture(t) {
12
+ const directory=await fs.mkdtemp(path.join(os.tmpdir(),'opencrawl-connect-')),before=process.env.OPENCRAWL_CONFIG_DIR;
13
+ process.env.OPENCRAWL_CONFIG_DIR=directory;t.after(async()=>{if(before===undefined)delete process.env.OPENCRAWL_CONFIG_DIR;else process.env.OPENCRAWL_CONFIG_DIR=before;await fs.rm(directory,{recursive:true,force:true});});
14
+ const calls=[],state={authorization:'pending',expires:Math.floor(Date.now()/1000)+900,configured:false,jobs:[],source:{id,kind:'composio',owner_id:user,workspace_id:workspace,name:'Synthetic app',status:'pending',config:{toolkit:'github',connected_account_id:'private_provider_id',_connection_attempt_hash:'private_hash'},visibility:'private',sync_interval_minutes:1440}};
15
+ const value=()=>({source_id:id,authorization:{state:state.authorization,attempt_pending:state.authorization==='pending',attempt_expires_at:state.expires},scope:{configured:state.configured,adapter_supported:true,required_fields:['repository'],missing_fields:state.configured?[]:['repository'],summary:state.configured?'Repository selected':'Choose repository'},ingestion:{state:state.authorization==='verified'?(state.configured?'idle':'config_required'):'idle',indexed_document_count:0,jobs:state.jobs},issue:null,coverage:{state:'unknown'}});
16
+ const client={baseUrl:'https://app.example',async request(route,options={}){
17
+ calls.push({route,...options});if(state.before)await state.before(route,options);
18
+ if(route==='/api/auth/me')return{user:{id:user},workspace:{id:workspace}};
19
+ if(route==='/api/v1/sources')return {items:[state.source,{...state.source,id:'src_'+'f'.repeat(32),owner_id:'usr_'+'f'.repeat(32)}]};
20
+ if(route.endsWith('/setup'))return value();
21
+ if(route==='/api/v1/sources/connect'||route.endsWith('/reconnect'))return{source_id:id,url:'https://connect.example/authorize?opaque=synthetic',attempt:nonce,expires_at:state.expires};
22
+ if(route.endsWith('/connection-check')){state.authorization='verified';state.source.status='active';if(state.configured)state.jobs=[job()];if(state.checkLost)throw failure();return value();}
23
+ if(options.method==='PATCH'){state.source={...state.source,...options.body,config:{...state.source.config,...options.body.config}};state.configured=!!state.source.config.repository;if(state.configureLost)throw failure();return state.source;}
24
+ if(route.endsWith('/sync')){state.jobs=[job()];if(state.syncLost)throw failure();return{job:state.jobs[0]};}
25
+ throw new Error('Unexpected fixture request '+route);
26
+ }};
27
+ const run=async(rest,options={})=>{let out='',err='';const code=await connectCommand(rest,{json:true,...options},client,{stdout:v=>out+=v,stderr:v=>err+=v});return{code,data:JSON.parse(out),out,err};};
28
+ const ledger=async()=>{const files=(await fs.readdir(path.join(directory,'connections'))).filter(v=>v.endsWith('.json'));assert.equal(files.length,1);const file=path.join(directory,'connections',files[0]);return{file,value:JSON.parse(await fs.readFile(file,'utf8'))};};
29
+ return{directory,calls,state,client,run,ledger};
30
+ }
31
+ test('commands preserve alias and reject invalid or privileged scope fields',()=>{
32
+ assert.equal(connectRequest(['github'],{}).toolkit,'github');assert.equal(connectRequest([],{query:'Salesforce'}).route,'/api/v1/sources/catalogue?q=Salesforce');
33
+ assert.deepEqual(connectRequest(['configure',id],{repository:'owner/repo','max-pages':'1','include-comments':'false','sync-every':'0'}).body,{sync_interval_minutes:0,config:{repository:'owner/repo',max_pages:1,include_comments:false}});
34
+ for(const [rest,options] of [[['check','../oops'],{}],[['start'],{}],[['status',id,'extra'],{}],[['configure',id],{repository:'../repo'}],[['configure',id],{channels:'C1,C1'}],[['configure',id],{'max-pages':'1e2'}],[['configure',id],{'include-comments':'yes'}],[['configure',id],{'instance-url':'https://evil.example'}],[['configure',id],{'connected-account-id':'other'}],[['configure',id],{}],[['check',id],{attempt:nonce}]])assert.throws(()=>connectRequest(rest,options));
35
+ });
36
+ test('private durable attempt survives restart but nonce and provider identities never appear in output',async t=>{
37
+ const f=await fixture(t),started=await f.run(['github']);assert.equal(started.code,0);assert.equal(started.data.source_id,id);assert.ok(started.data.authorization_url);assert.ok(!started.out.includes(nonce));
38
+ const {file,value}=await f.ledger();assert.equal(value.records[0].attempt,nonce);assert.equal((await fs.stat(file)).mode&0o777,0o600);assert.equal((await fs.stat(path.dirname(file))).mode&0o777,0o700);
39
+ const status=await f.run(['status',id]);assert.equal(status.data.receipt.receipt_id,started.data.receipt.receipt_id);assert.ok(!status.out.includes('private_provider_id'));assert.ok(!status.out.includes('private_hash'));assert.ok(!status.out.includes(nonce));
40
+ const checked=await f.run(['check',id]);assert.equal(checked.code,0);assert.equal(checked.data.setup.authorization.state,'verified');assert.match(checked.err,/may queue/);assert.equal((await f.ledger()).value.records[0].attempt,undefined);
41
+ assert.equal(f.calls.filter(v=>v.route.endsWith('/connection-check')).length,1);
42
+ });
43
+ test('status and setup are read-only even with a pending provider attempt',async t=>{
44
+ const f=await fixture(t);await f.run(['start','github']);f.calls.length=0;
45
+ for(const args of [['status'],['status',id],['setup',id]])assert.equal((await f.run(args)).code,0);
46
+ assert.ok(f.calls.every(v=>!v.method||v.method==='GET'));const list=await f.run(['status']);assert.equal(list.data.sources.length,1);
47
+ });
48
+ test('lost browser callback and repeated check reconcile verified setup without another write',async t=>{
49
+ const f=await fixture(t);await f.run(['github']);f.state.authorization='verified';f.state.configured=true;f.state.jobs=[job()];f.calls.length=0;
50
+ const checked=await f.run(['check',id]);assert.equal(checked.code,0);assert.equal(checked.data.setup.authorization.state,'verified');assert.ok(f.calls.every(v=>!v.method));
51
+ assert.equal((await f.ledger()).value.records[0].attempt,undefined);
52
+ });
53
+ test('lost check acceptance reads current setup and never repeats provider verification or first sync',async t=>{
54
+ const f=await fixture(t);await f.run(['github']);f.state.checkLost=true;f.state.configured=true;
55
+ const checked=await f.run(['check',id]);assert.equal(checked.code,0);assert.equal(checked.data.setup.authorization.state,'verified');
56
+ assert.equal(f.calls.filter(v=>v.route.endsWith('/connection-check')).length,1);assert.equal(f.calls.filter(v=>v.route.endsWith('/sync')).length,0);
57
+ const next=await f.run(['sync',id]);assert.equal(next.code,0);assert.equal(next.data.job.id,job().id);assert.equal(f.calls.filter(v=>v.route.endsWith('/sync')).length,0);
58
+ });
59
+ test('an unreconciled check preserves uncertainty and secrets stay private',async t=>{
60
+ const f=await fixture(t);await f.run(['github']);let lost=false;f.state.before=(route)=>{if(route.endsWith('/connection-check')){lost=true;throw failure();}if(route.endsWith('/setup')&&lost)throw failure();};
61
+ const checked=await f.run(['check',id]);assert.equal(checked.code,1);assert.equal(checked.data.receipt.operation.state,'unconfirmed');assert.ok(!checked.out.includes('ak_do_not_echo'));assert.ok(!checked.out.includes(nonce));
62
+ assert.equal(f.calls.filter(v=>v.route.endsWith('/connection-check')).length,1);
63
+ });
64
+ test('expired and replaced attempts require explicit reconnect, never stale nonce submission',async t=>{
65
+ const f=await fixture(t);await f.run(['github']);f.state.expires+=1;
66
+ const checked=await f.run(['check',id]);assert.equal(checked.code,1);assert.match(checked.data.error.message,/reconnect/);assert.equal(f.calls.filter(v=>v.route.endsWith('/connection-check')).length,0);
67
+ const restarted=await f.run(['reconnect',id]);assert.equal(restarted.code,0);assert.equal((await f.ledger()).value.records[0].state,'superseded');
68
+ });
69
+ test('lost start blocks blind repeated start and does not infer a source from the owner list',async t=>{
70
+ const f=await fixture(t);f.state.before=route=>{if(route==='/api/v1/sources/connect')throw failure();};
71
+ const started=await f.run(['github']);assert.equal(started.code,1);assert.equal(started.data.source_id,null);
72
+ assert.equal((await f.run(['github'])).code,1);assert.equal(f.calls.filter(v=>v.route==='/api/v1/sources/connect').length,1);
73
+ const status=await f.run(['status']);assert.equal(status.data.receipts[0].source_id,null);assert.equal(status.data.sources[0].id,id);
74
+ const fresh=await f.run(['reconnect',id]);assert.equal(fresh.code,0);assert.equal(fresh.data.source_id,id);assert.equal((await f.ledger()).value.records[0].source_id,null);
75
+ });
76
+ test('source-less rejected or lost starts require status then an explicitly acknowledged new attempt',async t=>{
77
+ const f=await fixture(t);f.state.before=route=>{if(route==='/api/v1/sources/connect')throw failure(503);};
78
+ const started=await f.run(['github']);assert.equal(started.code,1);assert.equal(started.data.source_id,null);
79
+ assert.equal((await f.run(['start','github'],{'acknowledge-unknown':true})).code,1);
80
+ await f.run(['status']);assert.equal((await f.run(['github'])).code,1);assert.equal(f.calls.filter(v=>v.route==='/api/v1/sources/connect').length,1);
81
+ f.state.before=null;const replacement=await f.run(['start','github'],{'acknowledge-unknown':true});assert.equal(replacement.code,0);assert.match(replacement.err,/may replace or create/);
82
+ const records=(await f.ledger()).value.records;assert.equal(records.length,2);assert.equal(records[0].source_id,null);assert.equal(records[0].state,'unconfirmed');assert.ok(records[0].new_start_acknowledged_at);assert.equal(records[1].source_id,id);
83
+ // A known source is never replaced through this source-less recovery option.
84
+ assert.equal((await f.run(['start','github'],{'acknowledge-unknown':true})).code,1);assert.equal(f.calls.filter(v=>v.route==='/api/v1/sources/connect').length,2);
85
+ });
86
+ test('fresh identity must match a saved account and revoked credentials cannot publish an attempt',async t=>{
87
+ const f=await fixture(t);f.client.profile={userId:user,workspaceId:'ws_'+'e'.repeat(32),server:f.client.baseUrl};
88
+ assert.equal((await f.run(['github'])).code,1);assert.equal(f.calls.length,1);await assert.rejects(fs.stat(path.join(f.directory,'connections')),{code:'ENOENT'});
89
+ f.client.profile=null;f.state.before=()=>{throw failure(401);};assert.equal((await f.run(['github'])).data.error.status,401);assert.equal(f.calls.filter(v=>v.method==='POST').length,0);
90
+ });
91
+ test('a changed server identity cannot reuse another namespace nonce',async t=>{
92
+ const f=await fixture(t);await f.run(['github']);f.client.baseUrl='https://second.example';
93
+ const checked=await f.run(['check',id]);assert.equal(checked.code,1);assert.equal(f.calls.filter(v=>v.route.endsWith('/connection-check')).length,0);
94
+ });
95
+ test('configure is explicit, preserves server-owned keys, never syncs, and retains unknown receipt',async t=>{
96
+ const f=await fixture(t);f.state.authorization='verified';f.state.configureLost=true;
97
+ const result=await f.run(['configure',id],{repository:'owner/repo','max-pages':'1','include-comments':'false','sync-every':'0'});assert.equal(result.code,1);
98
+ const patch=f.calls.find(v=>v.method==='PATCH');assert.deepEqual(patch.body,{sync_interval_minutes:0,config:{repository:'owner/repo',max_pages:1,include_comments:false}});assert.equal(f.calls.filter(v=>v.route.endsWith('/sync')).length,0);
99
+ const status=await f.run(['setup',id]);assert.equal(status.data.source.config.repository,'owner/repo');assert.equal(status.data.receipt.operation.state,'unconfirmed');assert.ok(!status.out.includes('private_provider_id'));
100
+ });
101
+ test('sync acceptance is durable and unknown completed work requires explicit acknowledgement before another sync',async t=>{
102
+ const f=await fixture(t);f.state.authorization='verified';f.state.configured=true;f.state.source.config.repository='owner/repo';f.state.syncLost=true;
103
+ const first=await f.run(['sync',id]);assert.equal(first.code,1);assert.equal(first.data.receipt.operation.state,'unconfirmed');
104
+ f.state.jobs[0].status='completed';assert.equal((await f.run(['configure',id],{name:'Renamed source'})).code,0);assert.equal((await f.run(['sync',id])).code,1);assert.equal(f.calls.filter(v=>v.route.endsWith('/sync')).length,1);
105
+ f.state.syncLost=false;const second=await f.run(['sync',id],{'acknowledge-unknown':true});assert.equal(second.code,0);assert.equal(second.data.job.id,job().id);assert.equal((await f.ledger()).value.records[0].operation.job_id,job().id);
106
+ });
107
+ test('missing required scope stops first sync',async t=>{
108
+ const f=await fixture(t);f.state.authorization='verified';const result=await f.run(['sync',id]);assert.equal(result.code,1);assert.equal(f.calls.filter(v=>v.route.endsWith('/sync')).length,0);
109
+ });
110
+ test('invalid authorization URLs or mismatched source receipts never publish a usable nonce',async t=>{
111
+ const f=await fixture(t),original=f.client.request.bind(f.client);
112
+ f.client.request=async(route,options)=>route==='/api/v1/sources/connect'?{source_id:id,url:'javascript:alert(1)',attempt:nonce,expires_at:f.state.expires}:original(route,options);
113
+ const result=await f.run(['github']);assert.equal(result.code,1);assert.equal(result.data.authorization_url,undefined);assert.ok(!result.out.includes(nonce));assert.equal((await f.ledger()).value.records[0].attempt,undefined);
114
+ });
115
+ test('corrupt, permissive, or symlinked receipt files fail closed before provider writes',async t=>{
116
+ const f=await fixture(t);await f.run(['github']);const {file}=await f.ledger();f.calls.length=0;
117
+ await fs.chmod(file,0o644);assert.equal((await f.run(['check',id])).code,1);
118
+ await fs.chmod(file,0o600);await fs.writeFile(file,'{broken');assert.equal((await f.run(['check',id])).code,1);
119
+ await fs.unlink(file);const target=path.join(f.directory,'outside');await fs.writeFile(target,'{}');await fs.symlink(target,file);assert.equal((await f.run(['check',id])).code,1);
120
+ assert.equal(f.calls.filter(v=>v.method==='POST').length,0);assert.equal(await fs.readFile(target,'utf8'),'{}');
121
+ });
122
+ test('concurrent start commands serialize locally and never duplicate provider creation',async t=>{
123
+ const f=await fixture(t);let release,entered;const blocked=new Promise(r=>release=r),ready=new Promise(r=>entered=r);
124
+ f.state.before=async route=>{if(route==='/api/v1/sources/connect'){entered();await blocked;}};
125
+ const first=f.run(['github']);await ready;const second=await f.run(['github']);assert.equal(second.code,1);assert.match(second.data.error.message,/still be running/);release();assert.equal((await first).code,0);
126
+ assert.equal(f.calls.filter(v=>v.route==='/api/v1/sources/connect').length,1);
127
+ });
128
+ test('human output escapes hostile source fields while JSON keeps exact safe metadata',async t=>{
129
+ const f=await fixture(t);f.state.source.name='Unsafe\x1b]0;title\x07';let text='';
130
+ assert.equal(await connectCommand(['status'],{},f.client,{stdout:v=>text+=v,stderr:v=>text+=v}),0);assert.ok(!text.includes('\x1b'));assert.match(text,/\\u001b/);
131
+ });
@@ -0,0 +1,32 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { coverageWarnings, hookContext, HOOK_CONTEXT_BUDGET, HOOK_RETRIEVAL_BUDGET, utf8Prefix } from './context.mjs';
4
+
5
+ test('UTF-8 prefixes stay inside the byte budget at every multibyte boundary', () => {
6
+ const text = 'Aé漢😀Z';
7
+ for (let budget = 0; budget <= Buffer.byteLength(text) + 2; budget++) {
8
+ const bounded = utf8Prefix(text, budget);
9
+ assert.ok(Buffer.byteLength(bounded) <= budget);
10
+ assert.ok(text.startsWith(bounded));
11
+ assert.ok(!bounded.includes('\ufffd'));
12
+ }
13
+ });
14
+
15
+ test('hook budget includes wrapper and coverage warning, with room reserved before retrieval', () => {
16
+ const data = { context: '漢😀'.repeat(1000), coverage: { partial_semantic: true, warnings: ['Semantic search covered only part of this workspace.'] } };
17
+ const output = hookContext(data);
18
+ assert.ok(output.includes('Retrieval coverage:'));
19
+ assert.ok(output.includes('untrusted'));
20
+ assert.ok(output.endsWith('</opencrawl_context>'));
21
+ assert.ok(!output.includes('\ufffd'));
22
+ assert.ok(Buffer.byteLength(output) <= HOOK_CONTEXT_BUDGET);
23
+ const fullEvidence = 'x'.repeat(HOOK_RETRIEVAL_BUDGET);
24
+ assert.ok(hookContext({ ...data, context: fullEvidence }).includes(fullEvidence));
25
+ });
26
+
27
+ test('missing coverage remains quiet and an empty partial search remains explicit', () => {
28
+ assert.deepEqual(coverageWarnings({ context: 'test' }), []);
29
+ assert.equal(hookContext({ context: '' }), '');
30
+ assert.match(hookContext({ context: '', coverage: { partial_semantic: true } }), /only part/);
31
+ assert.match(coverageWarnings({ evidence: { semantic_status: 'unavailable; lexical fallback' } })[0], /keyword matching/);
32
+ });
package/src/jobs.mjs CHANGED
@@ -48,17 +48,26 @@ export function validateJob(job, id = job?.id) {
48
48
  }
49
49
  export const getJob = async (client, id, options = {}) => validateJob(await client.request(`/api/v1/jobs/${encodeURIComponent(identifier(id))}`, { maxResponseBytes: 512 * 1024, ...options }), id);
50
50
 
51
+ // Failures that say nothing about the job itself: the server restarting, a proxy, the network. Polling keeps going.
52
+ const TRANSIENT=new Set([408,425,429,500,502,503,504]);
53
+ export function transient(error){return error?.transient===true||TRANSIENT.has(error?.status);}
54
+ // Wait before the next poll after a transient failure: 2s, 4s, 8s, then every 15s, never past the deadline.
55
+ export async function settle(failures,{signal,now,sleep,deadline}){const next=Math.min(deadline,now()+Math.min(15000,1000*2**failures));while(!signal?.aborted&&now()<next)await sleep(Math.min(100,next-now()));}
56
+
51
57
  export async function waitForJob(client, id, { timeout = 300, signal, now = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), onUpdate = () => {}, initial, deadline } = {}) {
52
58
  identifier(id); const started = now(); deadline ??= started + timeout * 1000;
53
- let job = initial ? validateJob(initial, id) : null;
59
+ let job = initial ? validateJob(initial, id) : null, failures = 0;
54
60
  const result = (outcome, error) => ({ job_id: id, job, wait: { outcome, elapsed_seconds: Math.max(0, Math.round((now() - started) / 1000)), ...(error ? { error } : {}) } });
55
61
  while (true) {
56
62
  if (signal?.aborted) return result('interrupted');
57
63
  if (job && TERMINAL.has(job.status)) return result(job.status);
58
64
  const remaining = deadline - now();
59
65
  if (remaining <= 0) return result('timeout');
60
- try { job = await getJob(client, id, { timeout: Math.max(1, Math.min(30000, Math.ceil(remaining))), signal }); }
61
- catch (error) { return result(signal?.aborted ? 'interrupted' : now() >= deadline ? 'timeout' : 'unavailable', requestFailure(error)); }
66
+ try { job = await getJob(client, id, { timeout: Math.max(1, Math.min(30000, Math.ceil(remaining))), signal }); failures = 0; }
67
+ catch (error) {
68
+ if (!signal?.aborted && now() < deadline && transient(error)) { await settle(++failures, {signal, now, sleep, deadline}); continue; }
69
+ return result(signal?.aborted ? 'interrupted' : now() >= deadline ? 'timeout' : 'unavailable', requestFailure(error));
70
+ }
62
71
  onUpdate(job);
63
72
  if (TERMINAL.has(job.status)) return result(job.status);
64
73
  // Short sleeps keep Ctrl+C responsive without extra server polling.
@@ -0,0 +1,67 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { jobsCommand, waitForJob, waitOptions, renderJob } from './jobs.mjs';
4
+ import { displayText, displayLine } from './display.mjs';
5
+
6
+ const id = 'job_' + 'a'.repeat(32);
7
+ const job = status => ({ id, status, kind: 'upload', indexed_documents: status === 'completed' ? 1 : 0, documents: [] });
8
+ test('job waiting follows exact IDs and stops on terminal status without any mutation', async () => {
9
+ let time = 0; const calls = []; const statuses = ['queued', 'running', 'completed'];
10
+ const result = await waitForJob({ request: async (route, options) => { calls.push([route, options]); return job(statuses.shift()); } }, id, { now: () => time, sleep: async ms => { time += ms; } });
11
+ assert.equal(result.wait.outcome, 'completed'); assert.equal(result.job_id, id); assert.equal(time, 4000);
12
+ assert.equal(calls.length, 3); assert(calls.every(([route, options]) => route === `/api/v1/jobs/${id}` && !options.method && options.timeout <= 30000));
13
+ });
14
+ test('all unsuccessful terminal states retain the job and return unsuccessful wait receipts', async () => {
15
+ for (const status of ['failed', 'cancelled', 'discarded']) {
16
+ const result = await waitForJob({ request: async () => job(status) }, id);
17
+ assert.equal(result.wait.outcome, status); assert.equal(result.job.id, id);
18
+ }
19
+ });
20
+ test('finite timeout and interruption do not cancel or retry work', async () => {
21
+ let time = 0, calls = 0;
22
+ const result = await waitForJob({ request: async (route, options) => { calls++; assert(options.timeout <= 1000); return job('waiting'); } }, id, { timeout: 1, now: () => time, sleep: async ms => { time += ms; } });
23
+ assert.equal(result.wait.outcome, 'timeout'); assert.equal(calls, 1); assert.equal(result.job_id, id);
24
+ const controller = new AbortController(); controller.abort();
25
+ const interrupted = await waitForJob({ request: async () => { throw Error('must not call'); } }, id, { signal: controller.signal });
26
+ assert.equal(interrupted.wait.outcome, 'interrupted'); assert.equal(interrupted.job_id, id);
27
+ });
28
+ test('permission and unknown failures never imply completion', async () => {
29
+ for (const status of [401,403,404,422,undefined]) {
30
+ let calls = 0;
31
+ const result = await waitForJob({ request: async () => { calls++; const error = Error('RAW PRIVATE BODY'); error.status = status; throw error; } }, id);
32
+ assert.equal(result.wait.outcome, 'unavailable'); assert.equal(result.job_id, id); assert.equal(calls, 1); assert(!JSON.stringify(result).includes('RAW PRIVATE'));
33
+ }
34
+ const result = await waitForJob({ request: async () => job('surprise') }, id);
35
+ assert.equal(result.wait.outcome, 'unavailable'); assert.equal(result.job, null);
36
+ });
37
+ test('jobs validate option bounds before calling the server', async () => {
38
+ const client = { request: async () => { throw Error('unexpected call'); } };
39
+ for (const [rest, options] of [[['wait', id], { timeout: '0' }], [['wait', id], { timeout: '3601' }], [['list'], { limit: '1e2' }], [['show', '../secret'], {}], [['show', id], { retry: true }], [['list', 'extra'], {}]]) {
40
+ await assert.rejects(jobsCommand(rest, options, client), error => !error.message.includes('unexpected call'));
41
+ }
42
+ assert.throws(() => waitOptions({ timeout: '1' }), /requires --wait/);
43
+ });
44
+ test('job human display escapes terminal controls while JSON retains source text', async () => {
45
+ const value = { ...job('failed'), filename: 'report\u001b]52;copy\u0007.pdf', error: 'error\rreplacement\u202e', documents: [{ id: 'doc', title: 'a\nb' }] };
46
+ assert(!renderJob(value).includes('\u001b')); assert(renderJob(value).includes('\\u001b')); assert(renderJob(value).includes('a\\nb'));
47
+ assert.equal(displayText('a\nb\tc\u001b'), 'a\nb\tc\\u001b'); assert.equal(displayLine('a\nb'), 'a\\nb');
48
+ let output = ''; await jobsCommand(['show', id], { json: true }, { request: async () => value }, { stdout: text => { output += text; } });
49
+ assert.equal(JSON.parse(output).filename, value.filename);
50
+ });
51
+
52
+ test('a restart, proxy error or lost connection keeps the wait going until the job finishes', async () => {
53
+ let time = 0, calls = 0;
54
+ const failures = [502, 503, 429, 'network'];
55
+ const result = await waitForJob({ request: async () => {
56
+ calls++;
57
+ const next = failures.shift();
58
+ if (next === undefined) return job('completed');
59
+ const error = Error('RAW PRIVATE BODY'); if (next === 'network') error.transient = true; else error.status = next; throw error;
60
+ } }, id, { now: () => time, sleep: async ms => { time += ms; } });
61
+ assert.equal(result.wait.outcome, 'completed'); assert.equal(calls, 5);
62
+ assert(time >= 2000 + 4000 + 8000 + 15000); // backs off instead of hammering a restarting server
63
+ // Still bounded by the caller's deadline.
64
+ time = 0;
65
+ const stuck = await waitForJob({ request: async () => { const error = Error('x'); error.status = 503; throw error; } }, id, { timeout: 60, now: () => time, sleep: async ms => { time += ms; } });
66
+ assert.equal(stuck.wait.outcome, 'timeout');
67
+ });