@getopencrawl/cli 0.1.1 → 0.2.0
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.
- package/README.md +75 -298
- package/bin/opencrawl.cjs +168 -0
- package/package.json +20 -14
- package/bin/opencrawl.mjs +0 -553
- package/browser.mjs +0 -166
- package/src/accounts.test.mjs +0 -223
- package/src/agent.mjs +0 -95
- package/src/agent.test.mjs +0 -58
- package/src/answer-contract.mjs +0 -112
- package/src/answer-view.mjs +0 -118
- package/src/browser.test.mjs +0 -72
- package/src/capture.mjs +0 -273
- package/src/capture.test.mjs +0 -171
- package/src/client.mjs +0 -183
- package/src/client.test.mjs +0 -34
- package/src/connect.mjs +0 -230
- package/src/connect.test.mjs +0 -131
- package/src/context.mjs +0 -34
- package/src/context.test.mjs +0 -32
- package/src/display.mjs +0 -4
- package/src/fixtures/letter-connections.json +0 -59
- package/src/fixtures/product-monitor-contract.json +0 -312
- package/src/fixtures/research-completion-hosted.json +0 -502
- package/src/fixtures/research-completion-v1.json +0 -58
- package/src/fixtures/research-completion-v2.json +0 -33
- package/src/fixtures/research-presentation-hosted.json +0 -720
- package/src/jobs.mjs +0 -117
- package/src/jobs.test.mjs +0 -67
- package/src/mcp-conversations.test.mjs +0 -80
- package/src/monitors.mjs +0 -146
- package/src/monitors.test.mjs +0 -62
- package/src/product-letter-capabilities.mjs +0 -71
- package/src/product-letter-read.mjs +0 -178
- package/src/product-monitor-login.test.mjs +0 -140
- package/src/product-monitors.mjs +0 -321
- package/src/product-monitors.test.mjs +0 -69
- package/src/research-agent-errors.test.mjs +0 -110
- package/src/research-agent-feedback.test.mjs +0 -28
- package/src/research-agent.mjs +0 -164
- package/src/research-agent.test.mjs +0 -231
- package/src/research-answer-contract.mjs +0 -3
- package/src/research-answer-contract.test.mjs +0 -14
- package/src/research-answer-view.mjs +0 -7
- package/src/research-answer-view.test.mjs +0 -27
- package/src/research-completion.mjs +0 -94
- package/src/research-completion.test.mjs +0 -110
- package/src/research-event.mjs +0 -39
- package/src/research-event.test.mjs +0 -57
- package/src/research-presentation.test.mjs +0 -56
- package/src/research.mjs +0 -141
- package/src/research.test.mjs +0 -97
- package/src/sessions.mjs +0 -165
- package/src/sessions.test.mjs +0 -151
- package/src/tasks.mjs +0 -115
- package/src/tasks.test.mjs +0 -83
- package/src/upload-commands.test.mjs +0 -140
- package/src/uploads.mjs +0 -141
- package/src/uploads.test.mjs +0 -64
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
/** Explicit read adapter. Diagnostics retain finite labels and query-free target hashes only. */
|
|
2
|
-
import {createHash} from 'node:crypto';
|
|
3
|
-
import {LETTER_CAPABILITIES_ADAPTER} from './product-letter-capabilities.mjs';
|
|
4
|
-
export const LETTER_ADAPTER='letter-connections-read-v1';
|
|
5
|
-
export const LETTER_SCREEN='https://app.letterstory.com/letterpress?view=connections';
|
|
6
|
-
const ORIGIN='https://app.letterstory.com',PREFIX=ORIGIN+'/api/console/tools/';
|
|
7
|
-
const READS=['list_cms_connections','list_sites','list_repo_sites'];
|
|
8
|
-
const BLOCKED=['prepare_account','refresh_site'];
|
|
9
|
-
// Observed unrelated startup inventory. These are denied, never fetched.
|
|
10
|
-
const BACKGROUND=['export_org_config','list_phantoms','list_collections','list_kernels','list_kernel_endpoints','list_shredder_endpoints','list_seers','list_arenas','list_lede_endpoints','list_mailers','list_prospect_lists','list_sequences','list_dispatches','list_simulations','letterprove_get_vendor_link','list_api_keys','list_integrations','visitors_list_sites','list_growth_builds'];
|
|
11
|
-
const ORG_QUERY=/^\?org_id=([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/;
|
|
12
|
-
function toolTarget(url){
|
|
13
|
-
for(const op of [...READS,...BLOCKED,...BACKGROUND]){
|
|
14
|
-
const base=PREFIX+op;if(url===base)return {op,org:null,url};
|
|
15
|
-
if(url.startsWith(base+'?')){const match=ORG_QUERY.exec(url.slice(base.length));if(match)return {op,org:match[1],url};}
|
|
16
|
-
}
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
function blockedTelemetry(url){
|
|
20
|
-
// Deny-only endpoints: query parameters cannot grant forwarding authority.
|
|
21
|
-
if(url==='https://app.letterprove.com/api/v1/observe')return true;
|
|
22
|
-
try{const u=new URL(url);return u.origin===ORIGIN&&u.pathname==='/ingest/flags/'&&!u.hash&&!u.username&&!u.password&&url===u.origin+u.pathname+u.search;}catch{return false;}
|
|
23
|
-
}
|
|
24
|
-
// Diagnostic classification is separate from toolTarget: it never grants forwarding
|
|
25
|
-
// or converts a fatal rejection into a known-background rejection.
|
|
26
|
-
function deniedTarget(req){
|
|
27
|
-
const value={method:'OTHER',origin_class:'invalid',known_target:'other',target_sha256:null};
|
|
28
|
-
try{const method=req.method();if(['GET','HEAD','OPTIONS','POST','PUT','PATCH','DELETE'].includes(method))value.method=method;}catch{}
|
|
29
|
-
try{
|
|
30
|
-
const raw=req.url();if(typeof raw!=='string'||raw.length>8192)return value;
|
|
31
|
-
const url=new URL(raw);if(!['http:','https:'].includes(url.protocol))return value;
|
|
32
|
-
value.origin_class=url.origin===ORIGIN?'letter_app':url.origin==='https://app.letterprove.com'?'letter_telemetry':'other';
|
|
33
|
-
if(url.origin===ORIGIN){
|
|
34
|
-
value.known_target=[...READS,...BLOCKED,...BACKGROUND].find(op=>url.pathname==='/api/console/tools/'+op)||'other';
|
|
35
|
-
if(url.pathname==='/ingest/flags/')value.known_target='telemetry_flags';
|
|
36
|
-
}else if(url.origin==='https://app.letterprove.com'&&url.pathname==='/api/v1/observe')value.known_target='telemetry_observe';
|
|
37
|
-
// URL origin excludes credentials; pathname excludes query and fragment.
|
|
38
|
-
// No percent-decoding: parser normalization is diagnostic, not authority.
|
|
39
|
-
value.target_sha256=createHash('sha256').update('letter-denied-target-v1\0'+url.origin+url.pathname).digest('hex');
|
|
40
|
-
}catch{}
|
|
41
|
-
return value;
|
|
42
|
-
}
|
|
43
|
-
const JSON_TYPE=/^application\/json(?:\s*;\s*charset=utf-8)?$/i;
|
|
44
|
-
const PROVIDERS={webflow:['site_id','site_name'],framer:['project_id','project_url','project_name','auto_deploy'],sanity:['project_id','dataset','project_name','workspace_name'],contentful:['space_id','environment_id','space_name','default_locale'],github:['account_login']};
|
|
45
|
-
const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
|
|
46
|
-
const keys=(v,allowed)=>object(v)&&Object.keys(v).length===allowed.length&&Object.keys(v).every(k=>allowed.includes(k));
|
|
47
|
-
const nullable=v=>v===null||typeof v==='string'&&v.length<=2048;
|
|
48
|
-
export function validConnections(v){
|
|
49
|
-
if(!keys(v,['connections'])||!Array.isArray(v.connections)||v.connections.length!==5)return false;
|
|
50
|
-
const seen=new Set();
|
|
51
|
-
return v.connections.every(row=>{
|
|
52
|
-
if(!keys(row,['provider','connected','label','created_at','fields'])||!Object.hasOwn(PROVIDERS,row.provider)||seen.has(row.provider)||typeof row.connected!=='boolean'||!nullable(row.label)||!nullable(row.created_at))return false;
|
|
53
|
-
seen.add(row.provider);
|
|
54
|
-
return keys(row.fields,PROVIDERS[row.provider])&&Object.entries(row.fields).every(([k,x])=>k==='auto_deploy'?typeof x==='boolean':nullable(x));
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
/** v3 keeps private rows bounded but does not equate capability discovery with five CMS names. */
|
|
58
|
-
export function validCapabilityConnections(v){
|
|
59
|
-
if(!keys(v,['connections'])||!Array.isArray(v.connections)||v.connections.length<1||v.connections.length>32)return false;
|
|
60
|
-
const seen=new Set();return v.connections.every(row=>{
|
|
61
|
-
if(!keys(row,['provider','connected','label','created_at','fields'])||typeof row.provider!=='string'||!(/^[a-z][a-z0-9_-]{0,63}$/).test(row.provider)||seen.has(row.provider)||typeof row.connected!=='boolean'||!nullable(row.label)||!nullable(row.created_at)||!object(row.fields)||Object.keys(row.fields).length>16)return false;
|
|
62
|
-
seen.add(row.provider);return Object.entries(row.fields).every(([k,x])=>/^[a-z][a-z0-9_]{0,63}$/.test(k)&&(typeof x==='boolean'||nullable(x)));
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
function connectionShape(v){
|
|
66
|
-
const rows=Array.isArray(v?.connections)?v.connections:[];
|
|
67
|
-
return {root_keys:keys(v,['connections']),five_rows:Array.isArray(v?.connections)&&rows.length===5,provider_roster:rows.length===5&&new Set(rows.map(r=>r?.provider)).size===5&&rows.every(r=>object(r)&&Object.hasOwn(PROVIDERS,r.provider)),row_keys:rows.length===5&&rows.every(r=>keys(r,['provider','connected','label','created_at','fields'])),row_types:rows.length===5&&rows.every(r=>object(r)&&typeof r.connected==='boolean'&&nullable(r.label)&&nullable(r.created_at)),field_keys:rows.length===5&&rows.every(r=>object(r)&&Object.hasOwn(PROVIDERS,r.provider)&&keys(r.fields,PROVIDERS[r.provider])),field_types:rows.length===5&&rows.every(r=>object(r?.fields)&&Object.entries(r.fields).every(([k,x])=>k==='auto_deploy'?typeof x==='boolean':nullable(x)))};
|
|
68
|
-
}
|
|
69
|
-
export function isLetterPolicy(policy,screen){return policy?.version===2&&policy.request_adapter===LETTER_ADAPTER&&screen.url===LETTER_SCREEN&&screen.account_marker?.required===true;}
|
|
70
|
-
|
|
71
|
-
export class LetterReadAttempt {
|
|
72
|
-
constructor(page,deadline,capabilities=false){this.capabilities=capabilities;this.shared=new Map();this.shareTimer=null;this.coalesced=0;this.page=page;this.deadline=deadline;this.requests=new Map();this.seen=new Set();this.scopeSet=false;this.org=null;this.required='missing';this.requiredChecks={};this.readStates=Object.fromEntries(READS.map(op=>[op,{state:'not_requested',phase:null,reason:null,checks:{}}]));this.readiness={state:'not_checked',in_flight:0};this.invalid=false;this.closed=false;this.inFlight=0;this.counts={allowed_reads:0,blocked_setup:0,blocked_refresh:0,blocked_background:0,blocked_other:0};this.denials={version:1,samples:[],omitted:0};}
|
|
73
|
-
/** Called for each non-read-only request before any forwarding. */
|
|
74
|
-
request(req){
|
|
75
|
-
const url=req.url(),target=toolTarget(url),op=target?.op;
|
|
76
|
-
if(this.capabilities&&(this.closed||this.invalid||performance.now()>=this.deadline))return this.reject(req);
|
|
77
|
-
let exact=false,reason='unavailable';
|
|
78
|
-
try{
|
|
79
|
-
reason=req.method()!=='POST'?'method':req.frame()!==this.page.mainFrame()?'frame':req.redirectedFrom()?'redirect':this.page.url()!==LETTER_SCREEN?'selected_page':req.headers().origin!==ORIGIN?'request_origin':null;
|
|
80
|
-
exact=reason===null;
|
|
81
|
-
}catch{}
|
|
82
|
-
if(exact&&(BLOCKED.includes(op)||BACKGROUND.includes(op)||blockedTelemetry(url))){const key=op==='prepare_account'?'blocked_setup':op==='refresh_site'?'blocked_refresh':'blocked_background';this.counts[key]=Math.min(64,this.counts[key]+1);return 'block_known';}
|
|
83
|
-
if(!exact)return this.reject(req,reason);
|
|
84
|
-
if(!READS.includes(op))return this.reject(req,'target');
|
|
85
|
-
if(req.postData()!=='{}')return this.reject(req,'body_shape');
|
|
86
|
-
if(!JSON_TYPE.test(req.headers()['content-type']||''))return this.reject(req,'content_type');
|
|
87
|
-
if(!this.capabilities&&this.seen.has(op))return this.reject(req,'duplicate_read');
|
|
88
|
-
if(this.scopeSet&&this.org!==target.org)return this.reject(req,'organization_scope');
|
|
89
|
-
if(this.capabilities){
|
|
90
|
-
let shared=this.shared.get(op);
|
|
91
|
-
if(shared){
|
|
92
|
-
if(shared.url!==url||this.requests.has(req)||shared.followers>=2)return this.reject(req,'duplicate_read');
|
|
93
|
-
shared.followers++;this.coalesced++;this.requests.set(req,{...target,shared,follower:true,started:false});this.inFlight++;return 'allow';
|
|
94
|
-
}
|
|
95
|
-
shared={url,followers:0,value:null};shared.done=new Promise(resolve=>{shared.resolve=resolve});this.shared.set(op,shared);
|
|
96
|
-
target.shared=shared;target.started=false;this.inFlight++;
|
|
97
|
-
// One absolute timer also releases followers of a slow or never-started leader.
|
|
98
|
-
if(this.shareTimer===null)this.shareTimer=setTimeout(()=>{this.invalid=true;this.clearShared();},Math.max(0,this.deadline-performance.now()));
|
|
99
|
-
}
|
|
100
|
-
this.scopeSet=true;this.org=target.org;this.seen.add(op);this.requests.set(req,target);this.readStates[op].state='accepted';this.counts.allowed_reads++;return 'allow';
|
|
101
|
-
}
|
|
102
|
-
recordDenial(req,reason='unavailable',outerKind='non_read'){
|
|
103
|
-
try{
|
|
104
|
-
if(this.closed)return;
|
|
105
|
-
const sample={...deniedTarget(req),outer_kind:['non_read','navigation_scope','approved_read_redirect'].includes(outerKind)?outerKind:'non_read',reason:['method','frame','redirect','selected_page','request_origin','target','body_shape','content_type','duplicate_read','organization_scope'].includes(reason)?reason:'unavailable'};
|
|
106
|
-
const match=this.denials.samples.find(row=>Object.entries(sample).every(([key,value])=>row[key]===value));
|
|
107
|
-
if(match)match.count=Math.min(64,match.count+1);
|
|
108
|
-
else if(this.denials.samples.length<8)this.denials.samples.push({...sample,count:1});
|
|
109
|
-
else this.denials.omitted=Math.min(64,this.denials.omitted+1);
|
|
110
|
-
}catch{} // Diagnostic failure never changes denial or prevents route.abort.
|
|
111
|
-
}
|
|
112
|
-
clearShared(){for(const item of this.shared.values()){item.value=null;item.resolve(false);}clearTimeout(this.shareTimer);this.shareTimer=null;}
|
|
113
|
-
shareActive(req,target){return !this.closed&&!this.invalid&&performance.now()<this.deadline&&this.page.url()===LETTER_SCREEN&&req.frame()===this.page.mainFrame()&&req.url()===target.url;}
|
|
114
|
-
reject(req,reason='unavailable',outerKind='non_read'){this.invalid=true;if(this.capabilities)this.clearShared();this.counts.blocked_other=Math.min(64,this.counts.blocked_other+1);this.recordDenial(req,reason,outerKind);return 'block';}
|
|
115
|
-
async replay(route,target){
|
|
116
|
-
const req=route.request();
|
|
117
|
-
try{
|
|
118
|
-
if(!this.shareActive(req,target))throw Error();
|
|
119
|
-
if(!await target.shared.done)throw Error();
|
|
120
|
-
const value=target.shared.value;
|
|
121
|
-
if(!value||!this.shareActive(req,target))throw Error();
|
|
122
|
-
// Detached validated JSON only: never repeat cookies or other response headers.
|
|
123
|
-
await route.fulfill({status:value.status,headers:{'content-type':value.contentType},body:value.bytes});
|
|
124
|
-
}catch{this.invalid=true;this.clearShared();await route.abort('blockedbyclient').catch(()=>{});}
|
|
125
|
-
finally{this.inFlight--;}
|
|
126
|
-
}
|
|
127
|
-
async forward(route){
|
|
128
|
-
const req=route.request(),target=this.requests.get(req),op=target?.op;
|
|
129
|
-
if(this.capabilities){
|
|
130
|
-
if(!target||target.started){this.reject(req);await route.abort('blockedbyclient').catch(()=>{});return;}
|
|
131
|
-
target.started=true;if(target.follower)return this.replay(route,target);
|
|
132
|
-
}
|
|
133
|
-
let response,phase='preflight',reason=null;const diagnostic=this.readStates[op];if(diagnostic)diagnostic.state='in_flight';if(!this.capabilities)this.inFlight++;
|
|
134
|
-
try{
|
|
135
|
-
const remaining=Math.floor(this.deadline-performance.now());
|
|
136
|
-
if(!op||this.closed||remaining<=0||this.capabilities&&!this.shareActive(req,target)){reason=!op?'unaccepted':this.closed?'closed':'deadline';throw Error();}
|
|
137
|
-
if(op==='list_cms_connections')this.required='pending';
|
|
138
|
-
// route.continue may follow redirects outside subsequent route handlers.
|
|
139
|
-
// Fetch exactly once with redirects/retries disabled, then fulfill only a valid response.
|
|
140
|
-
phase='fetch';response=await route.fetch({maxRedirects:0,maxRetries:0,timeout:remaining});
|
|
141
|
-
phase='protocol';const headers=response.headers(),status=response.status();
|
|
142
|
-
const checks={url_exact:response.url()===target.url,status_ok:status>=200&&status<300,content_type_json:JSON_TYPE.test(headers['content-type']||''),declared_size_ok:headers['content-length']===undefined||/^[0-9]+$/.test(headers['content-length'])&&Number(headers['content-length'])<=65536};
|
|
143
|
-
if(diagnostic)Object.assign(diagnostic.checks,checks);
|
|
144
|
-
if(op==='list_cms_connections')Object.assign(this.requiredChecks,checks);
|
|
145
|
-
if(this.closed||performance.now()>=this.deadline||!Object.values(checks).every(Boolean)){reason=this.closed?'closed':performance.now()>=this.deadline?'deadline':!checks.url_exact?'response_url':!checks.status_ok?'http_status':!checks.content_type_json?'content_type':'declared_size';throw Error();}
|
|
146
|
-
phase='body';const bytes=await response.body();
|
|
147
|
-
if(diagnostic)diagnostic.checks.body_size_ok=bytes.length<=65536;
|
|
148
|
-
if(op==='list_cms_connections')this.requiredChecks.body_size_ok=bytes.length<=65536;
|
|
149
|
-
if(bytes.length>65536){reason='body_size';throw Error();}
|
|
150
|
-
if(op==='list_cms_connections')this.requiredChecks.json_valid=false;
|
|
151
|
-
phase='json';if(diagnostic)diagnostic.checks.json_valid=false;const value=JSON.parse(bytes.toString('utf8'));if(diagnostic)diagnostic.checks.json_valid=true;
|
|
152
|
-
if(op==='list_cms_connections')Object.assign(this.requiredChecks,{json_valid:true,schema_valid:this.capabilities?validCapabilityConnections(value):validConnections(value),schema_shape:this.capabilities?{bounded_rows:Array.isArray(value?.connections)&&value.connections.length>=1&&value.connections.length<=32}:connectionShape(value)});
|
|
153
|
-
phase='schema';const valid=op==='list_cms_connections'?(this.capabilities?validCapabilityConnections(value):validConnections(value)):object(value)||Array.isArray(value);
|
|
154
|
-
if(diagnostic)diagnostic.checks.schema_valid=valid;
|
|
155
|
-
if(!valid){reason='schema';throw Error();}
|
|
156
|
-
if(this.closed||performance.now()>=this.deadline||this.capabilities&&!this.shareActive(req,target)){reason=this.closed?'closed':performance.now()>=this.deadline?'deadline':'attempt_invalid';throw Error();}
|
|
157
|
-
phase='fulfill';await route.fulfill({response,body:bytes});if(diagnostic)diagnostic.state='verified';
|
|
158
|
-
if(op==='list_cms_connections'&&!this.closed)this.required='verified';
|
|
159
|
-
if(this.capabilities){
|
|
160
|
-
if(!this.shareActive(req,target)){reason='attempt_invalid';throw Error();}
|
|
161
|
-
target.shared.value={status,contentType:headers['content-type'],bytes:Buffer.from(bytes)};
|
|
162
|
-
target.shared.resolve(true);
|
|
163
|
-
}
|
|
164
|
-
}catch{
|
|
165
|
-
if(diagnostic){diagnostic.state='failed';diagnostic.phase=phase;diagnostic.reason=reason||({preflight:'preflight_failed',fetch:'fetch_failed',protocol:'protocol_failed',body:'body_read_failed',json:'invalid_json',schema:'schema_failed',fulfill:'fulfill_failed'})[phase];}
|
|
166
|
-
this.invalid=true;if(this.capabilities)this.clearShared();if(op==='list_cms_connections')this.required='invalid';
|
|
167
|
-
await route.abort('blockedbyclient').catch(()=>{});
|
|
168
|
-
}finally{await response?.dispose().catch(()=>{});this.inFlight--;}
|
|
169
|
-
}
|
|
170
|
-
/** Finite wait within capture's original 60s deadline; never reissues a read. */
|
|
171
|
-
async ready(signal){
|
|
172
|
-
while(!this.closed&&!this.invalid&&!signal?.aborted&&(this.inFlight>0||['missing','pending'].includes(this.required))&&performance.now()<this.deadline)await new Promise(r=>setTimeout(r,Math.min(25,Math.max(0,this.deadline-performance.now()))));
|
|
173
|
-
const ready=!this.closed&&!this.invalid&&!signal?.aborted&&performance.now()<this.deadline&&this.inFlight===0&&this.required==='verified';
|
|
174
|
-
this.readiness={state:ready?'ready':this.closed?'closed':signal?.aborted?'cancelled':this.invalid?'invalid':'capture_deadline',in_flight:Math.min(this.capabilities?9:3,Math.max(0,this.inFlight))};return ready;
|
|
175
|
-
}
|
|
176
|
-
report(){return structuredClone({version:1,adapter:this.capabilities?LETTER_CAPABILITIES_ADAPTER:LETTER_ADAPTER,required_read:this.required==='verified'?'verified':this.required==='invalid'?'invalid':'missing',...this.counts,...(this.capabilities?{coalesced_reads:this.coalesced}:{}),required_response_checks:this.requiredChecks,reads:this.readStates,readiness:this.readiness,invalid:this.invalid,in_flight:Math.min(this.capabilities?9:3,Math.max(0,this.inFlight)),...(this.denials.samples.length||this.denials.omitted?{blocked_requests:this.denials}:{})});}
|
|
177
|
-
close(){this.closed=true;if(this.capabilities)this.clearShared();}
|
|
178
|
-
}
|
|
@@ -1,140 +0,0 @@
|
|
|
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
|
-
});
|