388 lines
22 KiB
JavaScript
388 lines
22 KiB
JavaScript
// Fresh-profile standalone Persimmons Chromium acceptance.
|
|
const {chromium}=require(process.env.PERSIMMONS_PLAYWRIGHT_MODULE || 'playwright-core');
|
|
const {randomBytes}=require('node:crypto');
|
|
const path=require('node:path');
|
|
const fs=require('node:fs');
|
|
const os=require('node:os');
|
|
const assert=require('node:assert/strict');
|
|
|
|
let phase='startup';
|
|
(async()=>{
|
|
const profile=fs.mkdtempSync(path.join(os.tmpdir(),'persimmons-chromium-'));
|
|
const context=await chromium.launchPersistentContext(profile,{
|
|
executablePath:process.env.PERSIMMONS_CHROMIUM,
|
|
headless:true,
|
|
viewport:{width:1280,height:1200},
|
|
ignoreDefaultArgs:['--disable-extensions'],
|
|
args:['--no-sandbox','--enable-unsafe-extension-debugging']
|
|
});
|
|
context.setDefaultTimeout(60000);
|
|
let cleanupSource;
|
|
try {
|
|
const browser=context.browser();
|
|
const cdp=await browser.newBrowserCDPSession();
|
|
const extensionId='kiolgjoohhgdohcafgelhikpgkbmckbg';
|
|
const localAssets={
|
|
'/':'index.html',
|
|
'/signin':'signin.html',
|
|
'/assets/app.js':'app.js',
|
|
'/assets/signin.js':'signin.js',
|
|
'/assets/version.mjs':'version.mjs',
|
|
'/assets/version.json':'version.json'
|
|
};
|
|
if(process.env.PERSIMMONS_LOCAL_UI==='1'){
|
|
await context.route('https://persimmons.app/**',route=>{
|
|
const pathname=new URL(route.request().url()).pathname;
|
|
const file=localAssets[pathname];
|
|
if(!file)return route.continue();
|
|
const body=fs.readFileSync(path.join('persimmons/web',file));
|
|
const contentType=file.endsWith('.json')?'application/json':/\.m?js$/.test(file)?'text/javascript':'text/html';
|
|
return route.fulfill({status:200,contentType,body});
|
|
});
|
|
}
|
|
|
|
const page=await context.newPage();
|
|
let response=await page.goto('https://persimmons.app/',{waitUntil:'domcontentloaded'});
|
|
assert.equal(response.status(),200);
|
|
await page.locator('#signInLink').waitFor({state:'visible'});
|
|
await page.locator('#createAccount').waitFor({state:'visible'});
|
|
assert.equal(await page.getByText('Up to date',{exact:true}).count(),0);
|
|
assert.equal(await page.locator('#updateExtension').isVisible(),false);
|
|
|
|
phase='signup';
|
|
const username='browser-'+randomBytes(8).toString('hex');
|
|
const accountPassword=randomBytes(24).toString('base64url');
|
|
await page.locator('#createAccount').click();
|
|
await page.getByRole('heading',{name:'Create account'}).waitFor();
|
|
await page.locator('#modalName').fill(username);
|
|
await page.locator('#modalPassword').fill(accountPassword);
|
|
await page.locator('#siteModalBody form button[type="submit"]').click();
|
|
await page.locator('#signOutButton').waitFor({state:'visible'});
|
|
assert.equal(await page.locator('#signInLink').isVisible(),false);
|
|
|
|
const identity=await page.evaluate(()=>fetch('/api/session').then(r=>r.json()));
|
|
const giteaHeaders={Authorization:'Basic '+Buffer.from(username+':'+accountPassword).toString('base64')};
|
|
cleanupSource=async()=>{
|
|
const root='https://git.persimmons.app/api/v1';
|
|
const response=await context.request.get(root+'/users/'+username+'/tokens',{headers:giteaHeaders});
|
|
if(response.status()!==200)throw new Error();
|
|
for(const token of await response.json())if(token.name.startsWith('persimmons-juice-')){
|
|
const removed=await context.request.delete(root+'/users/'+username+'/tokens/'+token.id,{headers:giteaHeaders});
|
|
if(![204,404].includes(removed.status()))throw new Error();
|
|
}
|
|
const secrets=root+'/repos/'+username+'/work-test/actions/secrets';
|
|
const listed=await context.request.get(secrets,{headers:giteaHeaders});
|
|
if(listed.status()===200)for(const secret of await listed.json())if(secret.name.startsWith('PERSIM_JUICE_')){
|
|
if((await context.request.delete(secrets+'/'+secret.name,{headers:giteaHeaders})).status()!==204)throw new Error();
|
|
}
|
|
};
|
|
|
|
const gitUser=await context.request.get('https://git.persimmons.app/api/v1/user',{headers:giteaHeaders});
|
|
assert.equal(gitUser.status(),200);
|
|
const user=await gitUser.json();
|
|
assert.equal(user.login,username);assert.equal(user.id,identity.gitea_id);assert.equal(user.is_admin,false);
|
|
await page.getByRole('heading',{name:'Master vault',exact:true}).waitFor();
|
|
phase='named-vault';
|
|
await page.locator('#newVault').click();
|
|
await page.locator('#vaultName').fill('work-test');
|
|
await page.getByRole('button',{name:'Create vault',exact:true}).click();
|
|
await page.locator('#namedVaultList').getByText(username+'/work-test',{exact:true}).waitFor();
|
|
await page.locator('#namedVaultList').getByText('Empty vault',{exact:true}).waitFor();
|
|
const gitRepo=await context.request.get('https://git.persimmons.app/api/v1/repos/'+username+'/work-test',{headers:giteaHeaders});
|
|
assert.equal(gitRepo.status(),200);
|
|
const repo=await gitRepo.json();
|
|
assert.equal(repo.owner.id,user.id);assert.equal(repo.private,true);assert.equal(repo.empty,true);
|
|
await page.locator('#newVault').click();
|
|
await page.locator('#vaultName').fill('work-test');
|
|
await page.getByRole('button',{name:'Create vault',exact:true}).click();
|
|
await page.locator('#vaultStatus').getByText('A repository with this name already exists.',{exact:true}).waitFor();
|
|
await page.getByRole('button',{name:'Close',exact:true}).click();
|
|
console.log(JSON.stringify({step:'gitea_browser_acceptance',username,gitea_id:user.id,is_admin:false,
|
|
repository:repo.full_name,repo_id:repo.id,private:true,empty:true,duplicate_conflict:true}));
|
|
|
|
phase='capture-fill';
|
|
const loaded=await cdp.send('Extensions.loadUnpacked',{path:path.resolve('persimmons-extension')});
|
|
assert.equal(loaded.id,extensionId);
|
|
await page.reload({waitUntil:'domcontentloaded'});
|
|
await page.getByText('Open 🍊 and choose Link vault.',{exact:true}).waitFor();
|
|
assert.equal(await page.locator('#getExtension').isVisible(),false);
|
|
assert.equal(await page.locator('#updateExtension').isVisible(),false);
|
|
assert.equal(await page.getByText('Up to date',{exact:true}).count(),0);
|
|
|
|
// Automates the user's one-time approval of the optional Persimmons host permission.
|
|
const settings=await context.newPage();
|
|
await settings.goto('chrome://extensions');
|
|
await settings.evaluate(async id=>chrome.developerPrivate.addHostPermission(id,'https://persimmons.app/*'),extensionId);
|
|
await settings.close();
|
|
|
|
let target=await context.newPage();
|
|
let submissions=0;
|
|
target.on('request',r=>{if(r.method()==='POST')submissions++});
|
|
await target.goto('https://wp.phly.cloud/wp-login.php',{waitUntil:'domcontentloaded'});
|
|
await target.locator('#user_login').waitFor();
|
|
await target.locator('#user_login').fill('disposable-fixture');
|
|
await target.locator('#user_pass').fill('synthetic-no-provider-authority');
|
|
|
|
const tabInfo=()=>cdp.send('Target.getTargets',{filter:[{type:'tab',exclude:false}]})
|
|
.then(x=>x.targetInfos.find(t=>t.url.startsWith('https://wp.phly.cloud/wp-login.php')));
|
|
let info=await tabInfo();
|
|
assert.ok(info,'controlled HTTPS login tab exists');
|
|
|
|
let popupInfo,sessionId,sequence=0;
|
|
async function openPopup(){
|
|
await target.bringToFront();
|
|
await cdp.send('Extensions.triggerAction',{id:extensionId,targetId:info.targetId});
|
|
for(let n=0;n<60;n++){
|
|
popupInfo=(await cdp.send('Target.getTargets')).targetInfos.find(t=>t.url.startsWith('chrome-extension://'+extensionId+'/popup.html'));
|
|
if(popupInfo)break;
|
|
await new Promise(r=>setTimeout(r,100));
|
|
}
|
|
assert.ok(popupInfo,'extension action popup opened');
|
|
({sessionId}=await cdp.send('Target.attachToTarget',{targetId:popupInfo.targetId,flatten:false}));
|
|
await rpc('Runtime.enable');
|
|
for(let n=0;n<60;n++){
|
|
if(await evaluate('document.readyState==="complete" && !!document.getElementById("human-connect")'))return;
|
|
await new Promise(r=>setTimeout(r,100));
|
|
}
|
|
throw new Error('popup did not become ready');
|
|
}
|
|
function rpc(method,params={}){
|
|
const id=++sequence;
|
|
return new Promise((resolve,reject)=>{
|
|
const timer=setTimeout(()=>{cdp.off('Target.receivedMessageFromTarget',listener);reject(new Error('popup RPC timeout: '+method))},20000);
|
|
const listener=event=>{
|
|
if(event.sessionId!==sessionId)return;
|
|
const message=JSON.parse(event.message);
|
|
if(message.id!==id)return;
|
|
clearTimeout(timer);cdp.off('Target.receivedMessageFromTarget',listener);
|
|
message.error?reject(new Error(message.error.message||'popup RPC failed')):resolve(message.result);
|
|
};
|
|
cdp.on('Target.receivedMessageFromTarget',listener);
|
|
cdp.send('Target.sendMessageToTarget',{sessionId,message:JSON.stringify({id,method,params})}).catch(reject);
|
|
});
|
|
}
|
|
async function evaluate(expression){
|
|
const r=await rpc('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true,userGesture:true});
|
|
if(r.exceptionDetails)throw new Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);
|
|
return r.result.value;
|
|
}
|
|
async function click(id){await evaluate(`document.getElementById(${JSON.stringify(id)}).click()`)}
|
|
async function waitValue(expression,expected,label){
|
|
for(let n=0;n<120;n++){
|
|
const value=await evaluate(expression);
|
|
if(value===expected)return;
|
|
await new Promise(r=>setTimeout(r,100));
|
|
}
|
|
throw new Error('Expected '+label+': '+expected+'; got '+await evaluate(expression));
|
|
}
|
|
async function waitPageText(text){
|
|
await page.getByText(text,{exact:true}).waitFor({state:'visible',timeout:20000});
|
|
}
|
|
|
|
await openPopup();
|
|
assert.equal(await evaluate('document.getElementById("origin").textContent'),'https://wp.phly.cloud');
|
|
assert.equal(await evaluate('document.getElementById("human-state").textContent'),'Not connected');
|
|
assert.equal(await evaluate('document.querySelector("#human-username")'),null);
|
|
assert.equal(await evaluate('document.querySelector("#human-password")'),null);
|
|
|
|
await click('human-connect');
|
|
await waitValue('document.getElementById("human-state").textContent','Locked','linked state');
|
|
await waitPageText('Open 🍊 and choose a vault passphrase.');
|
|
|
|
const passphrase='disposable browser vault passphrase';
|
|
await evaluate(`document.getElementById("human-passphrase").value=${JSON.stringify(passphrase)}`);
|
|
await click('human-unlock');
|
|
await waitValue('document.getElementById("human-state").textContent','Unlocked','unlocked state');
|
|
await page.locator('#extensionState[aria-label="Vault unlocked"]').waitFor();
|
|
assert.equal(await evaluate('document.getElementById("human-capture").hidden'),false);
|
|
|
|
await click('human-capture');
|
|
await waitValue('document.getElementById("human-accounts").hidden',false,'saved item UI');
|
|
assert.equal(await evaluate('document.getElementById("human-items").options.length'),2);
|
|
console.log(JSON.stringify({step:'capture',ok:true,origin:'https://wp.phly.cloud'}));
|
|
|
|
await click('human-lock');
|
|
await waitValue('document.getElementById("human-state").textContent','Locked','locked state');
|
|
await page.locator('#extensionState[aria-label="Vault locked"]').waitFor();
|
|
|
|
await evaluate(`document.getElementById("human-passphrase").value=${JSON.stringify(passphrase)}`);
|
|
await click('human-unlock');
|
|
await waitValue('document.getElementById("human-state").textContent','Unlocked','re-unlocked state');
|
|
await evaluate('document.getElementById("human-items").selectedIndex=1;document.getElementById("human-items").dispatchEvent(new Event("change"))');
|
|
await click('human-fill');
|
|
assert.equal(await target.locator('#user_login').inputValue(),'disposable-fixture');
|
|
assert.equal(await target.locator('#user_pass').inputValue(),'synthetic-no-provider-authority');
|
|
assert.equal(submissions,0,'fill never submits');
|
|
await waitValue('document.getElementById("human-delete").disabled',false,'delete enabled after fill');
|
|
|
|
await click('human-delete');
|
|
await waitValue('document.getElementById("human-accounts").hidden',true,'empty site state');
|
|
console.log(JSON.stringify({step:'fill_delete',ok:true,no_submit:true}));
|
|
|
|
phase='juice-capture';
|
|
// Real Gitea credential capture -> drag -> derived write-only PAT -> rotate/revoke.
|
|
target = await context.newPage();
|
|
phase='juice-source-login';
|
|
await target.goto('https://git.persimmons.app/user/login',{waitUntil:'domcontentloaded'});
|
|
await target.locator('input[name="user_name"]').fill(username);
|
|
await target.locator('input[name="password"]').fill(accountPassword);
|
|
info=(await cdp.send('Target.getTargets',{filter:[{type:'tab',exclude:false}]})).targetInfos.find(t=>t.url.startsWith('https://git.persimmons.app/user/login'));
|
|
popupInfo=null;sessionId=null;
|
|
phase='juice-popup';
|
|
await openPopup();
|
|
await waitValue('document.getElementById("human-unlock").disabled',false,'source popup connected');
|
|
await evaluate(`document.getElementById("human-passphrase").value=${JSON.stringify(passphrase)}`);
|
|
await click('human-unlock');
|
|
await waitValue('document.getElementById("human-state").textContent','Unlocked','Juice source unlocked');
|
|
phase='juice-lock-policy';
|
|
await evaluate('document.getElementById("auto-lock").value="15";document.getElementById("auto-lock").dispatchEvent(new Event("change"))');
|
|
await waitValue('document.getElementById("human-state").textContent','Locked','policy change locks');
|
|
await evaluate(`document.getElementById("human-passphrase").value=${JSON.stringify(passphrase)}`);
|
|
await click('human-unlock');
|
|
await waitValue('document.getElementById("human-state").textContent','Unlocked','persistent unlock');
|
|
phase='juice-capture-click';
|
|
await click('human-capture');
|
|
await waitValue('document.getElementById("human-accounts").hidden',false,'Gitea source captured');
|
|
phase='juice-source-card';
|
|
await page.bringToFront();
|
|
if(popupInfo)await cdp.send('Target.closeTarget',{targetId:popupInfo.targetId}).catch(()=>{});
|
|
await page.getByRole('heading',{name:'Master vault',exact:true}).click();
|
|
const sourceCard=page.locator('#vaultItems .connection').filter({hasText:'https://git.persimmons.app'});
|
|
await sourceCard.waitFor();
|
|
await page.locator('#extensionState[aria-label="Vault unlocked"]').waitFor();
|
|
assert.equal(await sourceCard.getAttribute('draggable'),'true');
|
|
const sourceItemId=await sourceCard.getAttribute('data-source-id');
|
|
let pageLeaks=false;
|
|
page.on('response',async r=>{try{const text=await r.text();if(text.includes(accountPassword)||(r.url().includes('/api/juice/')&&/[0-9a-f]{40}/.test(text)))pageLeaks=true}catch{}});
|
|
const worker=context.serviceWorkers().find(w=>w.url().endsWith('/connection.mjs'));
|
|
assert.ok(worker,'real extension worker');
|
|
// Verify newly issued PATs in worker memory; never return their values to the harness/page.
|
|
await worker.evaluate(({username})=>{
|
|
const original=globalThis.fetch, tokens=new Map();
|
|
globalThis.juiceProof={createdWorks:0,revokedDenied:0,writeOnlyPosts:0};
|
|
globalThis.fetch=async function(url,options={}){
|
|
const response=await original(url,options);
|
|
const provider='https://git.persimmons.app/api/v1/users/'+username+'/tokens';
|
|
if(url===provider&&options.method==='POST'&&response.status===201){
|
|
const data=await response.clone().json();
|
|
tokens.set(data.name,data.sha1);
|
|
const check=await original('https://git.persimmons.app/api/v1/repos/'+username+'/work-test',
|
|
{headers:{Authorization:'token '+data.sha1},credentials:'omit',redirect:'error',cache:'no-store'});
|
|
if(check.status===200)globalThis.juiceProof.createdWorks++;
|
|
data.sha1='';
|
|
}
|
|
if(typeof url==='string'&&url.startsWith(provider+'/')&&options.method==='DELETE'&&response.status===204){
|
|
const name=url.slice(provider.length+1),pat=tokens.get(name);
|
|
if(pat){
|
|
const check=await original('https://git.persimmons.app/api/v1/repos/'+username+'/work-test',
|
|
{headers:{Authorization:'token '+pat},credentials:'omit',redirect:'error',cache:'no-store'});
|
|
if(check.status===401)globalThis.juiceProof.revokedDenied++;
|
|
tokens.delete(name);
|
|
}
|
|
}
|
|
if(url==='https://persimmons.app/api/juice/write'&&options.method==='POST'&&response.ok)globalThis.juiceProof.writeOnlyPosts++;
|
|
return response;
|
|
};
|
|
},{username});
|
|
await page.evaluate(()=>{
|
|
window.dragProof=[];
|
|
for(const type of ['dragstart','drop','dragend'])document.addEventListener(type,event=>window.dragProof.push({type,target:event.target.className,types:[...event.dataTransfer.types]}));
|
|
});
|
|
phase='juice-drag';
|
|
await sourceCard.scrollIntoViewIfNeeded();
|
|
const from=await sourceCard.boundingBox(),to=await page.locator('.namedVaultCard').boundingBox();
|
|
await page.mouse.move(from.x+from.width-25,from.y+20);
|
|
await page.mouse.down();
|
|
await page.mouse.move(from.x+from.width-25,from.y+30,{steps:4});
|
|
await page.mouse.move(to.x+to.width/2,to.y+to.height/2,{steps:20});
|
|
await page.mouse.move(to.x+to.width/2,to.y+to.height/2+2);
|
|
await page.mouse.up();
|
|
await page.screenshot({path:'/tmp/persimmons-juice-drag.png'});
|
|
console.log(JSON.stringify({step:'drag_events',events:await page.evaluate(()=>window.dragProof)}));
|
|
await page.getByRole('button',{name:'Create Juice',exact:true}).click();
|
|
await page.locator('#siteModal').waitFor({state:'hidden'});
|
|
let cards=page.locator('.juiceCard');
|
|
await cards.first().waitFor();
|
|
const firstId=await cards.first().getAttribute('data-juice-id');
|
|
const providerTokens=async()=>{
|
|
const r=await context.request.get('https://git.persimmons.app/api/v1/users/'+username+'/tokens',{headers:giteaHeaders});
|
|
assert.equal(r.status(),200);return r.json();
|
|
};
|
|
let tokens=await providerTokens();
|
|
assert.ok(tokens.some(t=>t.name==='persimmons-juice-'+firstId));
|
|
assert.ok(tokens.every(t=>!t.sha1));
|
|
const secretsPath='https://git.persimmons.app/api/v1/repos/'+username+'/work-test/actions/secrets';
|
|
const listed=await context.request.get(secretsPath,{headers:giteaHeaders});
|
|
assert.equal(listed.status(),200);
|
|
assert.ok((await listed.json()).some(s=>s.name==='PERSIM_JUICE_'+firstId.toUpperCase()));
|
|
assert.ok([404,405].includes((await context.request.get(secretsPath+'/PERSIM_JUICE_'+firstId.toUpperCase(),{headers:giteaHeaders})).status()));
|
|
assert.equal(await cards.first().isVisible(),true,'target vault remains open');
|
|
phase='juice-rotate';
|
|
await cards.first().getByRole('button',{name:'Rotate',exact:true}).click();
|
|
await page.locator('.juiceCard').filter({hasText:' · active · '}).waitFor();
|
|
for(let n=0;n<100;n++){
|
|
tokens=await providerTokens();
|
|
if(tokens.length===1&&tokens[0].name!=='persimmons-juice-'+firstId)break;
|
|
await new Promise(r=>setTimeout(r,200));
|
|
}
|
|
assert.equal(tokens.length,1);assert.notEqual(tokens[0].name,'persimmons-juice-'+firstId);
|
|
phase='juice-revoke';
|
|
const active=page.locator('.juiceCard[data-juice-id="'+tokens[0].name.slice('persimmons-juice-'.length)+'"]');
|
|
await active.waitFor();
|
|
await active.getByRole('button',{name:'Revoke',exact:true}).click();
|
|
for(let n=0;n<100;n++){
|
|
if((await providerTokens()).length===0)break;
|
|
await new Promise(r=>setTimeout(r,200));
|
|
}
|
|
assert.equal((await providerTokens()).length,0);
|
|
assert.deepEqual(await worker.evaluate(()=>globalThis.juiceProof),{createdWorks:2,revokedDenied:2,writeOnlyPosts:2});
|
|
assert.equal((await (await context.request.get(secretsPath,{headers:giteaHeaders})).json()).length,0);
|
|
assert.equal(pageLeaks,false,'source password never reaches Persimmons page responses');
|
|
assert.equal(await page.locator('body').innerText().then(s=>s.includes(accountPassword)),false);
|
|
const removed=await page.evaluate(async id=>(await fetch('/api/vault-index/'+id,{method:'DELETE',headers:{'X-Requested-With':'Persimmons'}})).status,sourceItemId);
|
|
assert.equal(removed,200);
|
|
console.log(JSON.stringify({step:'juice_browser_acceptance',ok:true,repo_id:repo.id,drag:true,write_only:true,rotated:true,revoked:true,page_password_hidden:true}));
|
|
|
|
phase='signout';
|
|
// Browser-action popups close when focus returns to the website. Reopening after
|
|
// sign-out must live-validate the owner hint and fail back to Not connected.
|
|
await page.bringToFront();
|
|
await page.locator('#signOutButton').click();
|
|
await page.locator('#signInLink').waitFor({state:'visible'});
|
|
popupInfo=null; sessionId=null;
|
|
await openPopup();
|
|
await waitValue('document.getElementById("human-state").textContent','Not connected','reopened signed-out state');
|
|
assert.equal(await evaluate('document.getElementById("human-connect-panel").hidden'),false);
|
|
console.log(JSON.stringify({step:'signout_sync',ok:true}));
|
|
|
|
console.log(JSON.stringify({
|
|
step:'browser_extension_acceptance',
|
|
ok:true,
|
|
extension_version:JSON.parse(fs.readFileSync('persimmons-extension/manifest.json')).version,
|
|
extension_id:extensionId,
|
|
fresh_profile:true,
|
|
same_document_auth:true,
|
|
link:true,
|
|
initialize_unlock:true,
|
|
homepage_popup_agree:true,
|
|
explicit_capture:true,
|
|
lock_unlock:true,
|
|
explicit_fill:true,
|
|
delete:true,
|
|
signout_clears_link:true,
|
|
current_version_quiet:true,
|
|
no_submit:submissions===0,
|
|
target:'https://wp.phly.cloud/wp-login.php'
|
|
}));
|
|
} finally {
|
|
if(cleanupSource)try{await cleanupSource()}catch{console.error('ANDON: disposable browser provider cleanup unconfirmed.');process.exitCode=1;}
|
|
for(const page of context.pages()){
|
|
if(page.url().startsWith('https://persimmons.app')){
|
|
await page.evaluate(()=>fetch('/api/logout',{method:'POST'})).catch(()=>{});
|
|
}
|
|
}
|
|
await context.close();
|
|
fs.rmSync(profile,{recursive:true,force:true});
|
|
}
|
|
})().catch(error=>{console.error('ANDON: browser acceptance failed at '+phase+' ('+error.name+'); credential diagnostics suppressed.');console.error(String(error.stack).split('\n').filter(l=>/^\s+at .*persimmons-host-browser\.cjs:\d/.test(l)).slice(0,2).join('\n'));process.exitCode=1});
|