|
| 1 | +#!/usr/bin/env node |
| 2 | +// Bring-your-own-key in a tab: set a key through the real command, ask the agent for something, and see |
| 3 | +// that the request went from the browser straight to the provider with that key — and that no LevelCode |
| 4 | +// account token was involved. Uses the same stand-in backend as e2e.mjs, as an OpenAI-compatible provider. |
| 5 | +// |
| 6 | +// node web/test/e2e-byok.mjs --static <vscode-web dir> --extensions <dir> |
| 7 | +// |
| 8 | +// The provider settings (custom, OpenAI-compatible, at the stand-in's address) are configuration defaults of |
| 9 | +// the development server; a release bakes its own defaults, so this runs against the development layout. |
| 10 | +import { spawn } from 'node:child_process'; |
| 11 | +import fs from 'node:fs'; |
| 12 | +import path from 'node:path'; |
| 13 | +import { fileURLToPath } from 'node:url'; |
| 14 | +import { startStub } from './stub-backend.mjs'; |
| 15 | +import { Chrome, sleep } from './cdp.mjs'; |
| 16 | + |
| 17 | +const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 18 | +const args = Object.fromEntries(process.argv.slice(2).reduce((a, v, i, all) => { if (v.startsWith('--')) { a.push([v.slice(2), all[i + 1] && !all[i + 1].startsWith('--') ? all[i + 1] : 'true']); } return a; }, [])); |
| 19 | +const STATIC = path.resolve(args.static || process.env.LEVELCODE_WEB_STATIC || ''); |
| 20 | +const EXTS = path.resolve(args.extensions || process.env.LEVELCODE_WEB_EXTENSIONS || ''); |
| 21 | +const PORT = Number(args.port || 8805); |
| 22 | +const editorOrigin = `http://localhost:${PORT}`; |
| 23 | +const KEY = ['sk', 'byok', 'test', '0123456789'].join('-'); |
| 24 | +const results = []; |
| 25 | +const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }; |
| 26 | + |
| 27 | +const stub = await startStub({ editorOrigin, port: args['stub-port'] ? Number(args['stub-port']) : 0 }); |
| 28 | +const defaults = JSON.stringify({ 'levelcode.ai.providerMode': 'byok', 'levelcode.ai.provider': 'custom', 'levelcode.ai.baseURL': stub.origin + '/v1' }); |
| 29 | +const server = spawn(process.execPath, [path.join(HERE, '..', 'serve.mjs'), '--port', String(PORT), |
| 30 | + '--static', STATIC, '--extensions', EXTS, '--extension-names', 'levelcode-ai,levelcode-web', '--account', stub.origin, '--config-defaults', defaults], { stdio: 'inherit' }); |
| 31 | +let chrome; |
| 32 | +const finish = async (code) => { try { chrome && chrome.close(); } catch {} try { server.kill(); } catch {} try { await stub.close(); } catch {} process.exit(code); }; |
| 33 | + |
| 34 | +try { |
| 35 | + for (let i = 0; i < 50; i++) { try { if ((await fetch(editorOrigin + '/')).ok) { break; } } catch {} await sleep(100); } |
| 36 | + chrome = await Chrome.launch({ port: Number(args['debug-port'] || 9556), args: ['--ignore-certificate-errors'] }); |
| 37 | + const page = await chrome.newPage(editorOrigin + '/'); |
| 38 | + await page.waitFor(() => page.eval(`!!document.querySelector('.monaco-workbench .part.editor')`), { ms: 90000, label: 'workbench' }); |
| 39 | + await page.waitFor(async () => (await page.eval(`[...document.querySelectorAll('.tabs-container .tab')].some((e) => /LevelCode AI/.test(e.getAttribute('aria-label') || ''))`)), { ms: 60000, label: 'chat tab' }); |
| 40 | + await sleep(2500); |
| 41 | + |
| 42 | + const palette = async (text) => { |
| 43 | + await page.key('F1', 'F1'); |
| 44 | + await page.waitFor(() => page.eval(`!!document.querySelector('.quick-input-widget:not([style*="display: none"]) .quick-input-box input')`), { ms: 8000, label: 'command palette' }); |
| 45 | + await page.type(text); |
| 46 | + await sleep(900); |
| 47 | + await page.key('Enter', 'Enter'); |
| 48 | + }; |
| 49 | + |
| 50 | + await palette('Set API Key'); |
| 51 | + await page.waitFor(() => page.eval(`!!document.querySelector('.quick-input-widget:not([style*="display: none"]) .quick-input-box input[type=password]')`), { ms: 10000, label: 'key prompt' }); |
| 52 | + const prompt = await page.eval(`(document.querySelector('.quick-input-widget .quick-input-message') || {}).textContent`); |
| 53 | + check('the key prompt says where the key is kept, truthfully for a tab', /this browser/i.test(String(prompt)) && !/keychain/i.test(String(prompt)), JSON.stringify(prompt)); |
| 54 | + await page.type(KEY); |
| 55 | + await page.key('Enter', 'Enter'); |
| 56 | + await sleep(800); |
| 57 | + |
| 58 | + // the chat webview |
| 59 | + const activate = async () => { |
| 60 | + const r = await page.eval(`(() => { const t = [...document.querySelectorAll('.tabs-container .tab')].find((e) => /LevelCode AI/.test(e.getAttribute('aria-label') || '')); const b = t.getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; })()`); |
| 61 | + await page.click(r.x, r.y); |
| 62 | + await sleep(700); |
| 63 | + }; |
| 64 | + await activate(); |
| 65 | + const inChat = async (expr) => { |
| 66 | + for (const [id, s] of page.sessions) { |
| 67 | + if (s.type !== 'iframe') { continue; } |
| 68 | + try { |
| 69 | + const v = await page.eval(`(() => { const f = document.getElementById('active-frame'); if (!f || !f.contentDocument || !f.contentDocument.getElementById('input')) { return { __no: true }; } const d = f.contentDocument; return { v: (${expr}) }; })()`, { sessionId: id }); |
| 70 | + if (v && !v.__no) { return v.v; } |
| 71 | + } catch { /* another frame */ } |
| 72 | + } |
| 73 | + return undefined; |
| 74 | + }; |
| 75 | + await page.waitFor(async () => (await inChat('!!d.getElementById("send")')) === true, { ms: 30000, label: 'chat' }); |
| 76 | + const label = await inChat(`(d.getElementById('st-key') || {}).textContent`); |
| 77 | + check('the chat shows that it is using a key of the user\'s own', /BYO|key/i.test(String(label)), JSON.stringify(label)); |
| 78 | + await inChat(`(() => { const i = d.getElementById('input'); i.focus(); if ('value' in i) { i.value = 'Please create a hello file'; } else { i.textContent = 'Please create a hello file'; } i.dispatchEvent(new Event('input', { bubbles: true })); d.getElementById('send').click(); return true; })()`); |
| 79 | + await page.waitFor(() => stub.state.chats >= 2, { ms: 60000, label: 'two model turns' }).catch(() => null); |
| 80 | + check('the agent reached the provider straight from the tab', stub.state.chats >= 2, 'turns: ' + stub.state.chats); |
| 81 | + const auth = stub.state.byokAuth || []; |
| 82 | + check('the request carried the user\'s key, and only that', auth.length > 0 && auth.every((a) => a === 'Bearer ' + KEY), JSON.stringify(auth.slice(0, 2))); |
| 83 | + check('no LevelCode account call was made (no sign-in, no gateway)', !stub.state.log.some((e) => /^\/api\/levelcode\/v1\/(ai|auth)\//.test(e.path)), JSON.stringify(stub.state.log.filter((e) => e.path.startsWith('/api')).map((e) => e.path).slice(0, 4))); |
| 84 | +} catch (e) { |
| 85 | + check('the run completed', false, String((e && e.stack) || e)); |
| 86 | +} |
| 87 | +const failed = results.filter((r) => !r).length; |
| 88 | +console.log(`\n${results.length - failed}/${results.length} checks passed`); |
| 89 | +await finish(failed ? 1 : 0); |
0 commit comments