gitoria
All repositories: gitoria
95.1 KB
// tests/browser.mjs — THE GATE of gitoria.worldapi.org (ticket #6: repos with their own address).// Its own gitoria server on its own storage, its OWN ident (a copy of ident's code without .env —// codes go to a mail sink, tests/identkit.mjs), real headless Chromes for everything visible.// `*.gitoria.test` is mapped to 127.0.0.1 inside Chrome (--host-resolver-rules), so a repo really// lives at http://<slug>.gitoria.test:<port>/ — the same mechanism as <slug>.gitoria.worldapi.org.//// node tests/browser.mjs (GITORIA_GATE_PORT server, default 8700; GITORIA_GATE_IDENT_PORT, default 8701;// GITORIA_GATE_CHROME "8702-8709" = Chrome debug ports)// Screenshots (390 / 1280 px) land in .scratch/gate-*.png. Every process started is stopped by PID.import { spawn, execFileSync } from 'node:child_process';import { request as httpRequest } from 'node:http';import { rmSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'node:fs';import { dirname, join, resolve } from 'node:path';import { fileURLToPath } from 'node:url';import { launchBrowser } from './cdp.mjs';import { launchFirefox } from './firefox.mjs';import { startIdent } from './identkit.mjs';import { startTickets } from './ticketskit.mjs';if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';const HERE = dirname(fileURLToPath(import.meta.url));const APP = resolve(HERE, '..');const BIN = join(APP, 'bin/hybriel');const PORT = Number(process.env.GITORIA_GATE_PORT || 8700);const IDENT_PORT = Number(process.env.GITORIA_GATE_IDENT_PORT || 8701);const API = `http://127.0.0.1:${PORT}`; // node's view of the serverconst BASE = `http://gitoria.test:${PORT}`; // the browser's view: the main address// ident as the BROWSER sees it: same site as gitoria.test (the selector's fetch carries ident's Lax cookie only same-site)const IDENT_B = `http://ident.gitoria.test:${IDENT_PORT}`;const REPO = (slug) => `http://${slug}.gitoria.test:${PORT}`;const TICKETS_PORT = Number(process.env.GITORIA_GATE_TICKETS_PORT || 8702);const FIREFOX_PORT = Number(process.env.GITORIA_GATE_FIREFOX || 8699); // WebDriver BiDi port of the gate's Firefoxconst [CH_FROM, CH_TO] = (process.env.GITORIA_GATE_CHROME || '8703-8709').split('-').map(Number);process.env.HL_CHROME_ARGS = '--host-resolver-rules=MAP *.gitoria.test 127.0.0.1, MAP gitoria.test 127.0.0.1';const SCRATCH = join(APP, '.scratch');const STORE = join(SCRATCH, 'gate-store');const ORIGINS = ['alpha', 'beta-2', 'gamma'].map(x => `http://${x}.gitoria.test:${PORT}`).join(',');const TICKETS_DIR = process.env.GITORIA_GATE_TICKETS_DIR || [resolve(APP, '../tickets.worldapi.org'), '/media/STORAGE/projects/tickets.worldapi.org'].find(d => existsSync(join(d, 'project.hl')));const IDENT_DIR = process.env.GITORIA_GATE_IDENT_DIR || [resolve(APP, '../ident.worldapi.org'), '/media/STORAGE/projects/ident.worldapi.org'].find(d => existsSync(join(d, 'project.hl')));rmSync(STORE, { recursive: true, force: true });mkdirSync(STORE, { recursive: true });let failures = 0, passes = 0;function check(label, ok, detail = '') {console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);if (ok) passes++; else failures++;}const sleep = (ms) => new Promise(r => setTimeout(r, ms));const J = JSON.stringify;const XSS = '</script><b id="xss">x</b><script>window.__xss=1</script>';let log = '';let server = null, ident = null, tickets = null, ff = null;const browsers = [];function startServer(extraEnv = {}) {log += '\n==== gitoria server start\n';server = spawn(BIN, ['project.hl'], {cwd: APP,env: { ...process.env, GITORIA_PORT: String(PORT), GITORIA_STORAGE: join(STORE, 'mpackdb'), GITORIA_SESSIONS: join(STORE, 'sessions') + '/', GITORIA_WATCH: '0', GITORIA_GIT: join(STORE, 'git'),GITORIA_PUBLIC_URL: BASE, IDENT_URL: IDENT_B, IDENT_EXCHANGE_URL: ident.base, ...extraEnv },stdio: ['ignore', 'pipe', 'pipe'],});server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);}async function serverUp() {for (let i = 0; i < 80; i++) { try { const r = await fetch(API + '/api/repos'); if (r.ok) return; } catch {} await sleep(250); }throw new Error('gitoria did not come up\n' + log);}async function stopServer() {if (!server) return;try { server.kill('SIGTERM'); } catch {}await new Promise(r => { if (server.exitCode !== null || server.signalCode !== null) return r(); server.once('exit', r); setTimeout(r, 3000); });server = null;}const SLOW = Number(process.env.GITORIA_GATE_SLOW || 3);function patient(page) {const waitFor = page.waitFor.bind(page);page.waitFor = (expr, o = {}) => waitFor(expr, { ...o, timeout: (o.timeout || 10000) * SLOW });const goto = page.goto.bind(page);page.goto = (url, o = {}) => goto(url, { ...o, timeout: (o.timeout || 15000) * SLOW });return page;}async function newPage() {const b = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });browsers.push(b);return patient(await b.newPage());}async function viewport(page, width, height) {await page.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 });await sleep(250);}async function shot(page, name) {const { data } = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64'));}const noOverflow = (page) => page.evaluate('document.documentElement.scrollWidth <= window.innerWidth');const hydrated = (page) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'page hydrated' });const txt = (page, sel) => page.evaluate(`(document.querySelector(${J(sel)}) || {}).textContent || null`);const has = (page, sel) => page.evaluate(`!!document.querySelector(${J(sel)})`);// INSIDE ident's selector (shadow DOM): an expression over its root `r`, and a REAL click (as in tickets' gate)const sh = (expr) => `(() => { const h = document.querySelector('#selector'); const r = h && h.shadowRoot; if (!r) return null; return (${expr}); })()`;async function shClick(page, inner, name = null) {const find = `(() => { const h = document.querySelector('#selector'); const r = h && h.shadowRoot; if (!r) return null; const el = ${name === null ? `r.querySelector(${J(inner)})` : `[...r.querySelectorAll(${J(inner)})].find(b => b.textContent === ${J(name)})`}; if (!el) return null; el.scrollIntoView({ block: 'center' }); const b = el.getBoundingClientRect(); if (!b.width) return null; return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; })()`;const box = await page.waitFor(find, { label: 'selector ' + inner + ' ' + (name || '') });const at = { x: Math.round(box.x), y: Math.round(box.y), button: 'left', clickCount: 1 };await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...at, buttons: 0 });await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...at, buttons: 1 });await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...at, buttons: 0 });}async function identSignIn(page, email) {await page.goto(IDENT_B + '/');await page.waitForSelector('#email');await hydrated(page);await page.type('#email', email);await page.click('#sendcode');// ident#20: a sent code NAVIGATES to ident's /code page; type only once that page is hydratedawait page.waitFor('/\\/code$/.test(location.pathname) && !!document.querySelector("#code")', { label: 'ident /code page' });await hydrated(page);await page.type('#code', ident.lastCode(email));await page.click('#verify');await page.waitForSelector('#signout', { timeout: 10000 });}// the login button of the page we are on → ident's "choose an identity" → backasync function buttonLogin(page) {await page.click('#loginbutton');await page.waitForSelector('#chooselist', { timeout: 10000 });await hydrated(page);await page.click('#chooselist li:nth-child(1) .choose');}async function nameIt(page, name) {await page.waitForSelector('#nameform');await hydrated(page);await page.type('#displayname', name);await page.click('#namesave');await page.waitFor('!document.querySelector("#nameform")', { label: 'name saved' });}async function createRepo(page, slug, description) {await page.evaluate('document.querySelector("#slug").value = ""; document.querySelector("#description").value = ""');await page.type('#slug', slug);if (description) await page.type('#description', description);await page.click('#createsave');}// the FIRST HTML of a page as the server sends it, for any Host (node's fetch cannot set Host) — gitoria#16const firstHtml = (path, host, cookie) => new Promise((res, rej) => {const rq = httpRequest({ host: '127.0.0.1', port: PORT, path, headers: { host, ...(cookie ? { cookie } : {}) } }, (r) => {let b = ''; r.setEncoding('utf8'); r.on('data', d => b += d); r.on('end', () => res({ status: r.statusCode, headers: r.headers, body: b }));});rq.on('error', rej); rq.end();});const bodyText = (html) => ((html.match(/<body>([\s\S]*)<\/body>/) || [])[1] || '').replace(/<script[\s\S]*?<\/script>/g, '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');const emitFace = async (event, payload, cookie) => {const r = await fetch(API + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: 1, event, payload }) });const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch {}return { status: r.status, raw: t, value: j && j.value };};try {// ---- our own ident; gitoria is registered with ONE origin: the main address ------------------------ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });const alice = await ident.signIn('[email protected]');const gateAcct = await ident.signIn('[email protected]');const APPKEY = await ident.registerApp(alice, 'gitoria (gate)', [BASE]);check('ident: our own ident runs (a copy without .env), gitoria is registered', /^pk_[0-9a-f]{32}$/.test(APPKEY.key) && !existsSync(join(STORE, 'ident', 'ident-code', '.env')));// our own tickets (a copy without .env), with a token for its user "gitoria" — gitoria opens tickets with ittickets = await startTickets({ ticketsDir: TICKETS_DIR, workDir: join(STORE, 'tickets'), port: TICKETS_PORT, ident, who: alice, origins: ORIGINS });check('tickets: our own tickets runs (a copy without .env), gitoria has an API token', /^tkt_[0-9a-f]{48}$/.test(tickets.token) && !existsSync(join(STORE, 'tickets', 'tickets-code', '.env')));const env = { IDENT_API_KEY: APPKEY.key, IDENT_API_SECRET: APPKEY.secret, GITORIA_TICKETS_URL: tickets.base };startServer(env);await serverUp();// ---- the API (reads are public) --------------------------------------------------------------let r = await fetch(API + '/api/repos');check('api: GET /api/repos is empty at the start', r.status === 200 && J(await r.json()) === '{"repos":[]}');r = await fetch(API + '/api/repos', { method: 'POST', body: '{}' });check('api: POST /api/repos → 405 (creating is a web action)', r.status === 405);r = await fetch(API + '/api/repos/nothing');check('api: unknown repo → 404', r.status === 404);r = await fetch(API + '/api/repos', { headers: { accept: 'text/markdown' } });check('api: Markdown read view of the list', /text\/markdown/.test(r.headers.get('content-type')) && /^# Repositories \(0\)/.test(await r.text()));// ---- the login button's server half: where does ?next= lead? ------------------------------------const cbCookie = 'gitoriasid=' + 'ab'.repeat(16);// a failed login is a PAGE now (components/loginfailed.hl, gitoria#16): the reason is parked in the session → /login/failedconst failedPage = async (url) => {const c = await fetch(url, { redirect: 'manual' });const ck = (c.headers.get('set-cookie') || '').split(';')[0];const pg = await firstHtml('/login/failed', `gitoria.test:${PORT}`, ck);return { status: c.status, location: c.headers.get('location'), cookie: ck, page: bodyText(pg.body), pageStatus: pg.status };};let lf = await failedPage(API + '/login/callback');check('login: /login/callback without a code → /login/failed says "ident sent no login code"', lf.status === 302 && lf.location === '/login/failed' && lf.pageStatus === 200 && /Login failed/.test(lf.page) && /ident sent no login code/.test(lf.page), J(lf));lf = await failedPage(API + '/login/callback?ident_code=' + 'ab'.repeat(24));check('login: an unknown code → /login/failed says "ident refused"', lf.status === 302 && lf.location === '/login/failed' && /ident refused/.test(lf.page), J(lf));const nexts = [['/', '/'], ['/x?y=1', '/x?y=1'], [REPO('alpha') + '/code', REPO('alpha') + '/code'], [REPO('alpha') + '/', REPO('alpha') + '/'], [REPO('alpha'), REPO('alpha') + '/'],['//evil.example', '/'], ['http://evil.example/', '/'], [REPO('www') + '/', '/'], [`http://alpha.gitoria.test:${PORT}.evil.example/`, '/'],[REPO('alpha') + '/a b', '/'], [REPO('a--b') + '/', '/'], [REPO('Alpha') + '/', '/'], ['/x\r\nSet-Cookie: a=b', '/'], ['/login/callback', '/'], ['', '/'],[`https://alpha.gitoria.test:${PORT}/`, '/'], [`http://x.y.gitoria.test:${PORT}/`, '/'],];let allNext = true, nextDetail = '';for (const [want, expect] of nexts) {const code = await ident.selectorCode(gateAcct, APPKEY, BASE);const c = await fetch(API + '/login/callback?next=' + encodeURIComponent(want) + '&ident_code=' + code, { redirect: 'manual' });const loc = c.headers.get('location');if (c.status !== 302 || loc !== expect) { allNext = false; nextDetail += ` [${J(want)} → ${c.status} ${loc}, wanted ${expect}]`; }}check('login: ?next= accepts a path or a valid repo address, everything else → /', allNext, nextDetail);const cookieCode = await ident.selectorCode(gateAcct, APPKEY, BASE);const cc = await fetch(API + '/login/callback?ident_code=' + cookieCode, { redirect: 'manual' });const setCookie = cc.headers.get('set-cookie') || '';check('login: the session cookie is shared by every repo address (Domain=.gitoria.test)', /^gitoriasid=[0-9a-f]{32};/.test(setCookie) && /Domain=\.gitoria\.test/.test(setCookie) && /HttpOnly/.test(setCookie), setCookie);// ---- the re-vendored Hybriel (mission 033): what the dropped local patches did is upstream now ------------r = await fetch(API + '/__hl/app.css');const css = await r.text();check('styles: /__hl/app.css is 200 and the token file\'s var() tokens are in :root and used (hybriel#39 upstream)', r.status === 200 && /:root\s*\{[^}]*--dark\s*:/.test(css) && /var\(--/.test(css), css.slice(0, 200));// ---- forged face session: nobody logged in may create --------------------------------------------let f = await emitFace('gitoriaCreate', ['forged', '', { user: { id: 'x' } }]);check('face: a forged trailing session argument creates nothing', !(await (await fetch(API + '/api/repos/forged')).ok) && !(f.value && f.value.repo), f.raw);f = await emitFace('gitoriaCreate', ['anon', '']);check('face: not logged in → refused with a message', f.value && /log in/.test(f.value.error || ''), f.raw);// ---- A = alice in a browser -----------------------------------------------------------------------const A = await newPage();await identSignIn(A, '[email protected]');await A.goto(BASE + '/');await A.waitFor('!!document.querySelector("#heading")', { label: 'main address shows the list' });await hydrated(A);check('main address: "Repositories", empty list, hint to log in, no create form', (await txt(A, '#heading')) === 'Repositories' && (await has(A, '#empty')) && (await has(A, '#loginhint')) && !(await has(A, '#createform')));check('signed out: the login button goes to ident /login with the app key and the callback', (await A.evaluate('document.querySelector("#loginbutton").getAttribute("href")')) === `${IDENT_B}/login?key=${APPKEY.key}&return=${encodeURIComponent(BASE + '/login/callback')}`);await buttonLogin(A);await A.waitFor(`location.href === ${J(BASE + '/')} && !!document.querySelector("#nameform")`, { label: 'A back with the name prompt' });check('button: back on the main address, logged in, asked for a display name', await has(A, '#whoami') && !(await has(A, '#loginbutton')));await nameIt(A, 'alice');await A.waitForSelector('#createform');check('named: the "create a repository" form appears', (await txt(A, '#whoami')) === 'alice');// ---- B = a signed-out viewer of the main address, for the live list ---------------------------------const B = await newPage();await B.goto(BASE + '/');await B.waitForSelector('#empty');await hydrated(B);// ---- create ---------------------------------------------------------------------------------------const errAfter = async (slug, want) => {await createRepo(A, slug, '');await A.waitFor(`new RegExp(${J(want)}).test(document.querySelector("#createerror").textContent)`, { label: 'error for ' + slug });const e = await txt(A, '#createerror');check(`create: "${slug}" is refused — ${want}`, new RegExp(want).test(e), e);return e;};await errAfter('Bad Slug', 'only have lowercase');await errAfter('www', 'reserved');await errAfter('ab-', 'starts and ends');await errAfter('ab--cd', 'two hyphens');await errAfter('-abc', 'starts and ends');check('create: nothing was stored by the refusals', J(await (await fetch(API + '/api/repos')).json()) === '{"repos":[]}');await A.evaluate('document.querySelector("#createerror").textContent = ""');await createRepo(A, 'alpha', 'The first repository');await A.waitForSelector('#created');check('create: "alpha" is created, the page says so and links to its address', /alpha/.test(await txt(A, '#created')) && (await A.evaluate('document.querySelector("#createdlink").href')) === REPO('alpha') + '/');check('create: the list shows alpha with description, owner alice and time', await A.evaluate(`(() => { const li = document.querySelector('#repos li'); return !!li && /alpha/.test(li.textContent) && /The first repository/.test(li.textContent) && /alice/.test(li.textContent) && /\\d{4}-\\d\\d-\\d\\d \\d\\d:\\d\\d/.test(li.textContent) && li.querySelector('a').href === ${J(REPO('alpha') + '/')}; })()`), await A.evaluate('document.querySelector("#repos li").outerHTML'));await B.waitFor('document.querySelectorAll("#repos li").length === 1', { label: 'B sees alpha live' });check('live: the signed-out viewer B sees alpha without a reload', /alpha/.test(await txt(B, '#repos')) && !(await has(B, '#empty')));await errAfter('alpha', 'taken');await A.evaluate('document.querySelector("#createerror").textContent = ""');await createRepo(A, 'beta-2', '');await A.waitFor('document.querySelectorAll("#repos li").length === 2', { label: 'A: two repos' });await B.waitFor('document.querySelectorAll("#repos li").length === 2', { label: 'B: two repos' });check('create: a second repo (with a hyphen) and the list is newest first', (await A.evaluate('[...document.querySelectorAll("#repos .slug")].map(a => a.textContent).join()')) === 'beta-2,alpha');r = await fetch(API + '/api/repos/alpha');const alphaRow = await r.json();check('api: GET /api/repos/alpha = slug, description, owner, address', alphaRow.slug === 'alpha' && alphaRow.description === 'The first repository' && alphaRow.owner === 'alice' && alphaRow.address === REPO('alpha') + '/' && /^[0-9a-z]{12}$/.test(alphaRow.id), J(alphaRow));r = await fetch(API + '/api/repos/alpha', { headers: { accept: 'text/markdown' } });const md = await r.text();check('api: Markdown read view of a repo', /^# alpha\n/.test(md), md);// ---- the address: <slug>.gitoria.test ------------------------------------------------------------------await A.goto(REPO('alpha') + '/');await A.waitFor('!!document.querySelector("#reponame")', { label: 'alpha at its own address' });check('alpha.<domain> shows that repo: name, description, address, owner', (await txt(A, '#reponame')) === 'alpha' && (await txt(A, '#repodescription')) === 'The first repository' && (await txt(A, '#repoaddress')) === REPO('alpha') + '/' && (await txt(A, '#repoowner')) === 'alice' && !(await has(A, '#createform')), await A.evaluate('document.querySelector("repo-home").innerText'));check('alpha.<domain>: the login is shared with the main address (same cookie)', (await txt(A, '#whoami')) === 'alice');check('alpha.<domain>: the title is the repo page, tab title set', (await A.evaluate('document.title')) !== '');await A.goto(REPO('nothing-here') + '/');await A.waitFor('!!document.querySelector("#missing")', { label: 'unknown address' });check('an address nobody created → "No such repository" with a link to the main address', (await txt(A, '#missing')) === 'No such repository' && (await A.evaluate('document.querySelector("#toall").href')) === BASE + '/');await A.goto(REPO('www') + '/');await A.waitFor('!!document.querySelector("#missing")', { label: 'reserved address' });check('a reserved address is no repo either', true);await A.goto(BASE + '/');await A.waitFor('!!document.querySelector("#repos li")', { label: 'back on main' });await A.click('#repos li:nth-child(2) .slug');await A.waitFor(`location.hostname === 'alpha.gitoria.test' && !!document.querySelector("#reponame")`, { label: 'click on alpha' });check('clicking a repo in the list opens its own address', (await txt(A, '#reponame')) === 'alpha');// ---- login FROM a repo address: through the main address and back --------------------------------------await identSignIn(B, '[email protected]');await B.goto(REPO('alpha') + '/');await B.waitFor('!!document.querySelector("#reponame")', { label: 'B at alpha' });check('signed out at alpha.<domain>: repo visible, login button there', (await has(B, '#loginbutton')) && !(await has(B, '#whoami')));await hydrated(B);await buttonLogin(B);await B.waitFor(`location.href === ${J(REPO('alpha') + '/')} && !!document.querySelector("#whoami")`, { label: 'B logged in back at alpha' });await B.waitFor('!!document.querySelector("#reponame")', { label: 'B: the repo page after the login' });check('login started at alpha.<domain> returns to alpha.<domain>, logged in', (await txt(B, '#whoami')) === 'alice' && (await txt(B, '#reponame')) === 'alpha', await B.evaluate('location.href + " " + document.body.innerText.slice(0, 300)'));await B.goto(BASE + '/');await B.waitForSelector('#createform');check('the same login holds on the main address (cookie for all addresses)', (await txt(B, '#whoami')) === 'alice');// ---- the homepage of a repo (ticket #8): README.md, or the $docs folder --------------------------------------check('a new repo has an empty bare git repository', existsSync(join(STORE, 'git', 'alpha.git', 'HEAD')), J(existsSync(join(STORE, 'git'))));const pushFiles = (slug, files) => { // a commit into the bare repo, the way a push would leave itconst work = join(STORE, 'work-' + slug);rmSync(work, { recursive: true, force: true }); mkdirSync(work, { recursive: true });const git = (...a) => execFileSync('git', ['-C', work, '-c', 'user.name=t', '-c', '[email protected]', ...a], { stdio: 'pipe' });git('init', '-q', '-b', 'main');for (const [p, c] of Object.entries(files)) { mkdirSync(dirname(join(work, p)), { recursive: true }); writeFileSync(join(work, p), c); }git('add', '-A'); git('commit', '-q', '-m', 'files');git('push', '-q', '-f', join(STORE, 'git', slug + '.git'), 'main');};const homeText = async (p) => { await p.waitFor('!document.querySelector("#docsloading") && (!!document.querySelector("#homepage") || !!document.querySelector("#nohomepage"))', { label: 'homepage answer' }); return await p.evaluate('document.body.innerText'); };await A.goto(REPO('alpha') + '/');check('an empty repo says it has no README.md yet', /no README\.md yet/.test(await homeText(A)));pushFiles('alpha', {'README.md': '# Alpha project\n\nSome **strong** and *soft* text with `code` and a [link](https://example.org/x).\n\n- one\n- two\n\n> quoted words\n\n| Name | Value |\n|---|---|\n| a | 1 |\n| b | 2 |\n\n---\n\n```\nlet x = 1 < 2\n```\n\n<script>window.__pwned = 1</script>\n\n[bad](javascript:alert(1))\n','docs/other.md': '# not the homepage\n',});await A.goto(REPO('alpha') + '/');const alphaText = await homeText(A);check('/ shows README.md as HTML: heading, strong, list, quote, table, rule, code block', await A.evaluate(`(() => { const m = document.querySelector('#homepage markdown-text'); return !!m && m.querySelector('h2')?.textContent === 'Alpha project' && m.querySelector('strong')?.textContent === 'strong' && m.querySelectorAll('ul li').length === 2 && m.querySelector('blockquote')?.textContent.trim() === 'quoted words' && m.querySelectorAll('table tr').length === 3 && m.querySelector('td')?.textContent === 'a' && !!m.querySelector('hr') && m.querySelector('pre code')?.textContent === 'let x = 1 < 2'; })()`), alphaText.slice(0, 400));check('README links are real links; javascript: and raw HTML stay text', await A.evaluate(`(() => { const m = document.querySelector('#homepage markdown-text'); return m.querySelector('a').getAttribute('href') === 'https://example.org/x' && !m.querySelector('script') && !window.__pwned && m.textContent.includes('<script>window.__pwned = 1</script>') && m.textContent.includes('[bad](javascript:alert(1))') && ![...m.querySelectorAll('a')].some(a => /javascript/.test(a.getAttribute('href'))); })()`));pushFiles('alpha', {'README.md': '# Root readme\n','$docs/b-second.md': '# Second doc\n\nText two.\n','$docs/a-first.md': '# First doc\n\nText one.\n','$docs/sub/c-third.md': '## Third doc\n','$docs/notes.txt': 'not markdown\n',});await A.goto(REPO('alpha') + '/');await homeText(A);check('with a $docs folder its Markdown files form the homepage (in path order), the root README does not', await A.evaluate(`(() => { const h = [...document.querySelectorAll('#homepage h2, #homepage h3')].map(x => x.textContent).join('|'); return h === 'First doc|Second doc|Third doc' && !document.body.innerText.includes('Root readme') && !document.body.innerText.includes('not markdown'); })()`), await A.evaluate('document.body.innerText.slice(0, 400)'));check('the homepage of another repo is untouched (beta-2 has no README.md)', await (async () => { await A.goto(REPO('beta-2') + '/'); return /no README\.md yet/.test(await homeText(A)); })());for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {await viewport(A, w, h);pushFiles('beta-2', { 'README.md': '# Wide table\n\n| a | b | c | d | e | f | g | h |\n|---|---|---|---|---|---|---|---|\n| aaaaaaaaaaaaaaa | bbbbbbbbbbbbbbbbb | cccccccccccccccccc | ddddddddddddddd | eeeeeeeeeeeeeee | ffffffffffffffff | ggggggggggggggg | hhhhhhhhhhhhhhh |\n\nhttps://example.org/a/very/long/url/that/should/wrap/somewhere/because/it/is/really/quite/long/indeed\n' });await A.goto(REPO('beta-2') + '/');await homeText(A);check(`layout ${w}px: the homepage has no horizontal overflow`, await noOverflow(A));await shot(A, `${name}-home`);}// ---- browsing code (ticket #9): /code, /branch/<name>, /commit/<id> ------------------------------------------const gitIn = (work, ...a) => execFileSync('git', ['-C', work, '-c', 'user.name=t', '-c', '[email protected]', ...a], { stdio: 'pipe' }).toString();const work = join(STORE, 'work-code');rmSync(work, { recursive: true, force: true }); mkdirSync(join(work, 'src', 'deep'), { recursive: true });gitIn(work, 'init', '-q', '-b', 'main');writeFileSync(join(work, 'README.md'), '# Code repo\n');writeFileSync(join(work, 'src', 'a.txt'), 'line one\n\n indented <b>&</b>\n');writeFileSync(join(work, 'src', 'deep', 'b.txt'), 'deep file\n');writeFileSync(join(work, 'my file.txt'), 'spaced name\n');writeFileSync(join(work, 'bin.dat'), Buffer.from([0, 1, 2, 255, 0, 254, 0, 0]));gitIn(work, 'add', '-A'); gitIn(work, 'commit', '-q', '-m', 'first commit');const firstSha = gitIn(work, 'rev-parse', 'HEAD').trim();writeFileSync(join(work, 'src', 'a.txt'), 'main changed\n');gitIn(work, 'commit', '-qam', 'second on main');const mainSha = gitIn(work, 'rev-parse', 'HEAD').trim();gitIn(work, 'checkout', '-q', '-b', 'feature/x', firstSha);writeFileSync(join(work, 'src', 'a.txt'), 'feature changed\n');writeFileSync(join(work, 'only-feature.txt'), 'f\n');gitIn(work, 'add', '-A'); gitIn(work, 'commit', '-q', '-m', 'feature work');gitIn(work, 'checkout', '-q', '-b', 'dev', firstSha);gitIn(work, 'push', '-q', '-f', join(STORE, 'git', 'alpha.git'), 'main', 'feature/x', 'dev');const codeReady = (p) => p.waitFor('!document.querySelector("#codeloading") && (!!document.querySelector("#codemessage") || !!document.querySelector("#codefile") || !!document.querySelector("#entries") || !!document.querySelector("#noentries"))', { label: 'code answer' });const names = (p, sel) => p.evaluate(`[...document.querySelectorAll(${J(sel)})].map(x => x.textContent.trim()).join('|')`);await viewport(A, 1280, 900);await A.goto(REPO('alpha') + '/code');await codeReady(A);check('/code: the main branch at its last commit (tree: folder first, then files)', await txt(A, '#coderef') === 'main' && (await names(A, '#entries li > :first-child')) === 'src|README.md|bin.dat|my file.txt' && (await txt(A, '#commitsubject')) === 'second on main' && (await txt(A, '#commitlink')) === mainSha.slice(0, 8), await A.evaluate('document.body.innerText.slice(0, 500)'));check('/code: latest commits and branches are listed, the main branch marked', (await names(A, '#commits a')) === mainSha.slice(0, 8) + '|' + firstSha.slice(0, 8) && (await names(A, '#branches li > :first-child')) === 'dev|feature/x|main' && /main branch/.test(await txt(A, '#branches')), await A.evaluate('document.querySelector("#branches").innerText'));await A.click('#entries a.dir');await A.waitFor('location.pathname === "/code/src" && !!document.querySelector("#crumbs strong") && document.querySelector("#crumbs strong").textContent === "src" && !!document.querySelector("#entries")', { label: 'folder opened' });check('a folder link opens the folder (client navigation), crumbs show the path', (await A.evaluate('location.pathname')) === '/code/src' && (await names(A, '#entries li > :first-child')) === 'deep|a.txt' && (await names(A, '#crumbs a, #crumbs strong')) === 'main|src', await A.evaluate('location.pathname + " " + document.body.innerText.slice(0, 300)'));await A.goto(REPO('alpha') + '/code/src/a.txt');await codeReady(A);check('/code/<file>: numbered lines with the exact text (HTML stays text, blank lines kept)', await A.evaluate(`(() => { const li = [...document.querySelectorAll('#lines li')]; return li.length === 1 && li[0].textContent === 'main changed'; })()`), await A.evaluate('document.body.innerText.slice(0, 300)'));await A.goto(REPO('alpha') + '/commit/' + firstSha + '/src/a.txt');await codeReady(A);check('/commit/<id>/<file>: the file as it was at that commit', await A.evaluate(`[...document.querySelectorAll('#lines li')].map(l => l.textContent).join('|')`) === 'line one|| indented <b>&</b>' && (await txt(A, '#codekind')) === 'Commit' && !(await has(A, '#lines script')), await A.evaluate('document.body.innerText.slice(0, 300)'));await A.goto(REPO('alpha') + '/commit/' + firstSha.slice(0, 7));await codeReady(A);check('/commit/<short id>: the whole project at that commit (an old tree, no later file)', (await names(A, '#entries li > :first-child')) === 'src|README.md|bin.dat|my file.txt' && (await txt(A, '#commitsubject')) === 'first commit' && (await txt(A, '#coderef')) === firstSha.slice(0, 8), await A.evaluate('document.body.innerText.slice(0, 300)'));await A.goto(REPO('alpha') + '/branch/feature/x');await codeReady(A);check('/branch/<name with a slash>: that branch at its last commit', (await txt(A, '#coderef')) === 'feature/x' && (await names(A, '#entries li > :first-child')) === 'src|README.md|bin.dat|my file.txt|only-feature.txt' && (await txt(A, '#commitsubject')) === 'feature work', await A.evaluate('document.body.innerText.slice(0, 300)'));await A.goto(REPO('alpha') + '/branch/feature/x/src/a.txt');await codeReady(A);check('/branch/<name>/<file>: the file on that branch', (await names(A, '#lines li')) === 'feature changed', await A.evaluate('document.body.innerText.slice(0, 300)'));await A.goto(REPO('alpha') + '/code/my%20file.txt');await codeReady(A);check('a file with a space in its name opens', (await names(A, '#lines li')) === 'spaced name');await A.goto(REPO('alpha') + '/code/bin.dat');await codeReady(A);check('a binary file is not shown as text', (await has(A, '#binary')) && !(await has(A, '#lines li')));for (const [path, want] of [['/code/nope', /No such path/], ['/branch/nobranch', /no branch 'nobranch'/], ['/commit/deadbeef', /No commit 'deadbeef'/], ['/commit/zz', /No such commit/], ['/code/src/nope/deeper', /No such path/]]) {await A.goto(REPO('alpha') + path);await codeReady(A);check(`${path}: a plain message, no crash`, want.test(await txt(A, '#codemessage') || ''), await A.evaluate('document.body.innerText.slice(0, 200)'));}await A.goto(BASE + '/');await A.waitForSelector('#createform');await hydrated(A);await createRepo(A, 'gamma', XSS);await A.waitFor('!!document.querySelector("#created")', { label: 'gamma created' });await A.goto(REPO('gamma') + '/code');await codeReady(A);check('an empty repo says it has no commits yet', /no commits yet/.test(await txt(A, '#codemessage') || ''), await A.evaluate('document.body.innerText.slice(0, 200)'));// "Add code to this repository" (gitoria#20): stands as plain text, not a collapsible, ONLY on the Code page of an// empty repo (gamma) — checked on every tab of an empty repo AND a filled one (alpha, has commits)check('empty repo /code: "Add code to this repository" stands as plain text (no <details>, no <summary>)', (await has(A, '#addcode')) && (await A.evaluate('document.querySelector("#addcode").tagName')) !== 'DETAILS' && !(await has(A, '#addcode summary')) && (await A.evaluate('!!document.querySelector("#clonecommand").offsetParent')), await A.evaluate('document.querySelector("#addcode") ? document.querySelector("#addcode").outerHTML.slice(0, 150) : "missing"'));const addCodeTabs = [['/', '#nohomepage, #homepage'], ['/pulls', '#pullsmessage, #pullshelp'], ['/releases', '#releasesmessage, #releaseshelp'], ['/tickets', '#ticketsnotconnected, #ticketlist'], ['/settings', '#connecttickets, #notconnected']];for (const [p, sel] of addCodeTabs) {await A.goto(REPO('gamma') + p);await A.waitFor(`!!document.querySelector(${J(sel)})`, { label: 'gamma ' + p + ' for addcode check' });check(`empty repo ${p}: no "Add code" box (only the Code page has it)`, !(await has(A, '#addcode')), await A.evaluate('document.body.innerText.slice(0, 150)'));}for (const [p, sel] of [['/code', '#entries, #codemessage'], ...addCodeTabs]) {await A.goto(REPO('alpha') + p);await A.waitFor(`!!document.querySelector(${J(sel)})`, { label: 'alpha ' + p + ' for addcode check' });check(`filled repo ${p}: no "Add code" box (already has commits)`, !(await has(A, '#addcode')), await A.evaluate('document.body.innerText.slice(0, 150)'));}await A.goto(REPO('alpha') + '/code');await codeReady(A);check('the owner sees "Make main" on the other branches, not on the main one', (await A.evaluate('document.querySelectorAll("#branches .setmain").length')) === 2, await A.evaluate('document.querySelector("#branches").innerText'));await hydrated(A); // the view is in the FIRST HTML now: wait for the page to be live before clickingawait A.click('#branches .setmain');await A.waitFor('document.querySelector("#coderef") && document.querySelector("#coderef").textContent === "dev"', { label: 'main branch changed' });check('"Make main" changes the main branch: /code now shows it (kept in the repo record)', (await txt(A, '#coderef')) === 'dev' && (await names(A, '#entries li > :first-child')) === 'src|README.md|bin.dat|my file.txt' && (await txt(A, '#commitsubject')) === 'first commit' && (await (await fetch(API + '/api/repos/alpha')).json()).branch === 'dev', await A.evaluate('document.body.innerText.slice(0, 300)'));await A.goto(REPO('alpha') + '/');await homeText(A);check('the homepage README follows the main branch too', await A.evaluate('!!document.querySelector("#homepage")'));const S = await newPage();await S.goto(REPO('alpha') + '/code');await codeReady(S);check('a signed-out viewer sees the code, but no "Make main"', (await txt(S, '#coderef')) === 'dev' && (await S.evaluate('document.querySelectorAll("#branches .setmain").length')) === 0);const forged = await emitFace('gitoriaSetBranch', ['alpha', 'main', 'code', '', 'x'], '');const still = await (await fetch(API + '/api/repos/alpha')).json();check('face: a stranger cannot change the main branch', still.branch === 'dev', J(forged) + J(still));for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {await viewport(A, w, h);await A.goto(REPO('alpha') + '/code');await codeReady(A);check(`layout ${w}px: /code has no horizontal overflow`, await noOverflow(A));await shot(A, `${name}-code`);await A.goto(REPO('alpha') + '/code/src/a.txt');await codeReady(A);check(`layout ${w}px: a file view has no horizontal overflow of the page`, await noOverflow(A));await shot(A, `${name}-file`);}await viewport(A, 1280, 900);// the connect flow in the browser: the owner (alice, logged in at tickets with the gate's cookie) clicks "Tickets: connect"// in the repo's settings, picks the project on tickets and continues back to the repoconst connectRepo = async (page, slug, projectValue, newTitle) => {const [ck, cv] = tickets.cookie.split('=');await page.send('Network.setCookie', { name: ck, value: cv, url: tickets.base });await page.goto(REPO(slug) + '/settings');await page.waitFor('!!document.querySelector("#connecttickets")', { label: 'settings: connect button' });await page.click('#connecttickets');await page.waitFor('location.origin === ' + J(tickets.base) + ' && !!document.querySelector("#connectform")', { label: 'tickets connect page' });await hydrated(page);if (newTitle) {await page.evaluate('(() => { const s = document.querySelector("#connectproject"); s.value = ""; s.dispatchEvent(new Event("change", { bubbles: true })); })()');await page.waitFor('!!document.querySelector("#connecttitleinput")', { label: 'new project title field' });await page.type('#connecttitleinput', newTitle);} else if (projectValue) {await page.evaluate(`(() => { const s = document.querySelector("#connectproject"); s.value = ${J(projectValue)}; s.dispatchEvent(new Event("change", { bubbles: true })); })()`);}await page.click('#connectconfirm');try { await page.waitFor('!!document.querySelector("#connectcontinue")', { label: 'tickets: connected' }); }catch (e) { throw new Error(e.message + ' | page: ' + await page.evaluate('(document.querySelector("#connectmessage") || {}).textContent + " / " + document.body.innerHTML.slice(0, 1500)')); }await page.click('#connectcontinue');await page.waitFor(`location.hostname === '${slug}.gitoria.test' && !!document.querySelector("#connectedto")`, { label: 'back in settings, connected' });};// ---- the repo's tickets (gitoria#10): /tickets lists tickets.worldapi.org's project, the first ticket makes it ----const PROJ = 'alpha.gitoria.test';await A.goto(REPO('alpha') + '/tickets');await A.waitFor('!!document.querySelector("#ticketsnotconnected")', { label: 'tickets page, not connected' });check('connect: an unconnected repo says so on /tickets, no form, no list', !(await has(A, '#newticketform')) && !(await has(A, '#ticketlist')));await S.goto(REPO('alpha') + '/settings');await S.waitFor('!!document.querySelector("#notconnected")', { label: 'S: settings' });check('connect: a signed-out viewer sees "not connected" and no connect button', !(await has(S, '#connecttickets')) && !(await has(S, '#disconnecttickets')));// the owner connects: the project exists in tickets (made by the tickets user of alice, its admin)const mk = await fetch(tickets.base + '/api/projects', { method: 'POST', headers: { authorization: 'Bearer ' + tickets.token, 'content-type': 'application/json' }, body: J({ title: PROJ, slug: PROJ }) });check('connect: the project for alpha exists in tickets', mk.status === 201 || mk.status === 200, String(mk.status));await connectRepo(A, 'alpha', null, null);check('connect: settings says connected to that project, with the disconnect button', /alpha/.test(await txt(A, '#connectedto')) && (await has(A, '#disconnecttickets')));check('connect: tickets shows the connection', J((await tickets.api(`/api/projects/${PROJ}/connections`)).json).includes('alpha'), J((await tickets.api(`/api/projects/${PROJ}/connections`))));await A.goto(REPO('alpha') + '/tickets');await A.waitFor('!!document.querySelector("#noticketsyet")', { label: 'tickets page, connected, no ticket yet' });check('tickets: /tickets of a connected repo without tickets says so, has the nav link and the form', (await has(A, '#navtickets')) && (await has(A, '#newticketform')) && !(await has(A, '#ticketlist')));await S.goto(REPO('alpha') + '/tickets');await S.waitFor('!!document.querySelector("#noticketsyet")', { label: 'S: tickets page' });await hydrated(S);check('tickets: a signed-out viewer sees the page with a login hint and no form', (await has(S, '#ticketsloginhint')) && !(await has(S, '#newticketform')));await hydrated(A);await A.evaluate('document.querySelector("#newticket").open = true');await A.type('#ticketsubject', ' ');await A.click('#ticketsave');await A.waitFor('!!document.querySelector("#newticketerror").textContent', { label: 'blank subject refused' });await A.evaluate('document.querySelector("#ticketsubject").value = ""; document.querySelector("#newticket").open = true');check('tickets: a blank subject is refused in words', /subject/.test(await txt(A, '#newticketerror')));await A.type('#ticketsubject', 'First <b>ticket</b>');await A.type('#ticketsummary', 'Some **words**');await A.click('#ticketsave');await A.waitFor('document.querySelectorAll("#ticketlist li").length === 1', { label: 'first ticket listed' });const t1 = (await tickets.api(`/api/projects/${PROJ}/tickets/1`)).json.ticket;check('tickets: a ticket opened in gitoria lands in the connected project, created by the person (alice = the tickets user "gitoria")', t1 && t1.subject === 'First <b>ticket</b>' && t1.state === 'open' && t1.summary === 'Some **words**' && t1.createdBy === 'gitoria', J(t1));check('tickets: the list row = #1, the subject as text (no HTML), state, link into tickets', await A.evaluate(`(() => { const li = document.querySelector('#ticketlist li'); return /#1/.test(li.textContent) && li.textContent.includes('First <b>ticket</b>') && !li.querySelector('b') && /open/.test(li.textContent) && li.querySelector('a.subject').href === ${J(tickets.base + '/projects/' + PROJ + '/1')}; })()`));// the server pushes `ticketOpened` BEFORE it answers the face, so the list row can show up before openIt clears the form// (seen once on hybriel master ff51cf46) — wait for the face's answer, the noticeawait A.waitFor('!!document.querySelector("#ticketnotice")', { label: 'notice after the face answered' }).catch(() => null);check('tickets: the form is cleared and says "Opened ticket #1"', (await A.evaluate('document.querySelector("#ticketsubject").value')) === '' && /#1/.test(await txt(A, '#ticketnotice')), J({ subject: await A.evaluate('document.querySelector("#ticketsubject").value'), notice: await txt(A, '#ticketnotice') }));await S.waitFor('document.querySelectorAll("#ticketlist li").length === 1', { label: 'S sees it live' });check('live: the signed-out viewer sees the new ticket without a reload', /First/.test(await txt(S, '#ticketlist')) && !(await has(S, '#noticketsyet')));// a ticket opened in tickets itself, and one with a name only tickets knows: shown on the next loadconst ext = await fetch(tickets.base + '/api/projects/' + PROJ + '/tickets', { method: 'POST', headers: { authorization: 'Bearer ' + tickets.token, 'content-type': 'application/json' }, body: J({ subject: 'From tickets itself' }) });check('tickets: a ticket made on tickets.worldapi.org directly → 201', ext.status === 201);await A.goto(REPO('alpha') + '/tickets');await A.waitFor('document.querySelectorAll("#ticketlist li").length === 2', { label: 'two tickets after reload' });check('tickets: after a reload both are listed, newest update first', /From tickets itself/.test(await txt(A, '#ticketlist li:first-child')) && /First/.test(await txt(A, '#ticketlist li:nth-child(2)')) && (await A.evaluate('document.querySelector("#ticketsall").href')) === tickets.base + '/projects/' + PROJ);check('tickets: another repo has none of them', (await tickets.api('/api/projects/beta-2.gitoria.test/tickets')).status === 404);await S.goto(REPO('beta-2') + '/tickets');await S.waitFor('!!document.querySelector("#ticketsnotconnected")', { label: 'beta-2 is not connected' });check('tickets: beta-2 is not connected and shows none of them', !(await has(S, '#ticketlist')));await S.goto(REPO('nothing-here') + '/tickets');await S.waitFor('!!document.querySelector("#missing")', { label: 'unknown repo tickets' });check('tickets: an address nobody created has no tickets page', true);f = await emitFace('gitoriaOpenTicket', ['alpha', 'anon', '']);check('face: not logged in → a ticket is refused', f.value && /log in/.test(f.value.error || '') && (await tickets.api(`/api/projects/${PROJ}/tickets`)).json.tickets.length === 2, f.raw);f = await emitFace('gitoriaOpenTicket', ['alpha', 'forged', '', { user: { id: 'x' } }]);check('face: a forged trailing session opens nothing', !(f.value && f.value.ticket) && (await tickets.api(`/api/projects/${PROJ}/tickets`)).json.tickets.length === 2, f.raw);f = await emitFace('gitoriaOpenTicket', ['no-such-repo', 'x', '']);check('face: an unknown repo → refused, no project made', f.value && /no such repository/.test(f.value.error || '') && (await tickets.api('/api/projects/no-such-repo.gitoria.test/tickets')).status === 404, f.raw);for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {await viewport(A, w, h);await A.goto(REPO('alpha') + '/tickets');await A.waitFor('document.querySelectorAll("#ticketlist li").length === 2', { label: 'tickets for layout' });check(`layout ${w}px: /tickets has no horizontal overflow`, await noOverflow(A));await shot(A, `${name}-tickets`);}await viewport(A, 1280, 900);await tickets.stop();await S.goto(REPO('alpha') + '/tickets');await S.waitFor('!!document.querySelector("#ticketserror")', { label: 'tickets down' });check('tickets down: the page says tickets.worldapi.org did not answer', /did not answer/.test(await txt(S, '#ticketserror')));tickets = await startTickets({ ticketsDir: TICKETS_DIR, workDir: join(STORE, 'tickets2'), port: TICKETS_PORT, ident, who: alice, origins: ORIGINS });// ---- #N links both ways (gitoria#18): the tickets copy is new; connect alpha again, then a REAL push over HTTP ----await fetch(tickets.base + '/api/projects', { method: 'POST', headers: { authorization: 'Bearer ' + tickets.token, 'content-type': 'application/json' }, body: J({ title: PROJ, slug: PROJ }) });await fetch(tickets.base + '/api/projects/' + PROJ + '/tickets', { method: 'POST', headers: { authorization: 'Bearer ' + tickets.token, 'content-type': 'application/json' }, body: J({ subject: 'Login is broken' }) });await connectRepo(A, 'alpha', null, null);check('connect: connecting again after tickets was replaced works (new key, page says connected)', /alpha/.test(await txt(A, '#connectedto')));const sid = (await A.cookies([REPO('alpha')])).find(c => /sid$/.test(c.name));const mkTok = await emitFace('gitoriaMakeToken', ['gate push'], sid.name + '=' + sid.value);const PUSHTOKEN = mkTok.value && mkTok.value.token;check('links: a push token for alice', /^gtr_[0-9a-f]{40}$/.test(PUSHTOKEN || ''), mkTok.raw);const lw = join(STORE, 'work-links');rmSync(lw, { recursive: true, force: true });execFileSync('git', ['clone', '-q', join(STORE, 'git', 'alpha.git'), lw], { stdio: 'pipe' });const lg = (...a) => execFileSync('git', ['-C', lw, '-c', 'user.name=alice', '-c', '[email protected]', ...a], { stdio: 'pipe' }).toString();const pushHttp = (...refs) => lg('-c', 'http.extraHeader=Host: alpha.gitoria.test:' + PORT, 'push', '-q', `http://alice:${PUSHTOKEN}@127.0.0.1:${PORT}/alpha.git`, ...refs);const ticketMd = async (n) => (await (await fetch(`${tickets.base}/api/projects/${PROJ}/tickets/${n}`, { headers: { accept: 'text/markdown' } })).text());writeFileSync(join(lw, 'login.txt'), 'x\n'); lg('add', '-A'); lg('commit', '-q', '-m', 'fix login, #1 and a bogus #99');const mentionSha = lg('rev-parse', 'HEAD').trim();pushHttp('HEAD:main');let tmd = '';for (let i = 0; i < 20 && !/Mentioned in commit/.test(tmd); i++) { tmd = await ticketMd(1); if (!/Mentioned in commit/.test(tmd)) await sleep(500); }check('links: a pushed commit "fix login, #1" → ticket 1 in tickets shows "Mentioned in commit" with the commit link', /Mentioned in commit/.test(tmd) && tmd.includes(mentionSha.slice(0, 8)) && tmd.includes('/commit/' + mentionSha), tmd.slice(0, 600));check('links: a ticket that does not exist (#99) is skipped quietly', (await tickets.api(`/api/projects/${PROJ}/tickets/99`)).status === 404);const cnt = (t) => (t.match(/Mentioned in commit/g) || []).length;pushHttp('HEAD:refs/heads/other');await sleep(1500);check('links: pushing the same commit again does not comment twice', cnt(await ticketMd(1)) === 1, String(cnt(await ticketMd(1))));await A.goto(REPO('alpha') + '/commit/' + mentionSha);await A.waitFor('!!document.querySelector("#reponame")', { label: 'commit page' });await sleep(1500);check('links: the commit page shows #1 as a link into the ticket', await A.evaluate(`(() => { const a = [...document.querySelectorAll('a')].filter(x => x.href === ${J(tickets.base + '/projects/' + PROJ + '/1')}); return a.length > 0; })()`), await A.evaluate(`(document.querySelector('#commitsubject') || {}).innerHTML + ' | ' + location.href`));// "fixes #1" in a merged pull request sets the ticket to reviewlg('checkout', '-q', '-b', 'fixbr'); writeFileSync(join(lw, 'fix.txt'), 'y\n'); lg('add', '-A'); lg('commit', '-q', '-m', '|||PR|main] Fix the login, fixes #1');pushHttp('fixbr');await A.goto(REPO('alpha') + '/pulls');await A.waitFor('!!document.querySelector("#reponame")', { label: 'pulls page' });await sleep(1500);check('links: the open pull request lists #1 as a link', await A.evaluate(`[...document.querySelectorAll('a')].some(x => x.href === ${J(tickets.base + '/projects/' + PROJ + '/1')} && x.closest('#pulllist'))`) || await A.evaluate(`[...document.querySelectorAll('a')].some(x => x.href === ${J(tickets.base + '/projects/' + PROJ + '/1')})`));check('links: an open pull request does not change the ticket', (await tickets.api(`/api/projects/${PROJ}/tickets/1`)).json.ticket.state === 'open');lg('checkout', '-q', 'main'); lg('merge', '-q', '--no-ff', '-m', 'merge the fix', 'fixbr');pushHttp('main');let st = '';for (let i = 0; i < 20 && st !== 'review'; i++) { st = (await tickets.api(`/api/projects/${PROJ}/tickets/1`)).json.ticket.stateSlug; if (st !== 'review') await sleep(500); }check('links: the pull request "fixes #1" is merged (pushed) → ticket 1 is set to review', st === 'review', st);const before2 = cnt(await ticketMd(1));pushHttp('main');await sleep(1000);check('links: a second push comments nothing twice and does not set it again', cnt(await ticketMd(1)) === before2 && before2 === 2 && ((await ticketMd(1)).match(/Fixed by the merged pull request/g) || []).length === 1);// disconnect from gitoria's side, and tickets' side sees it goneawait A.goto(REPO('alpha') + '/settings');await A.waitFor('!!document.querySelector("#disconnecttickets")', { label: 'settings, connected' });await hydrated(A);await A.click('#disconnecttickets');await A.waitFor('!!document.querySelector("#notconnected")', { label: 'disconnected' });await A.goto(REPO('alpha') + '/tickets');await A.waitFor('!!document.querySelector("#ticketsnotconnected")', { label: 'tickets page after disconnect' });check('connect: "Forget the connection" → /tickets says not connected again', true);await connectRepo(A, 'alpha', null, null);// ---- pull requests (gitoria#11): /pulls lists the commits that start with |||PR -------------------------------const pw = join(STORE, 'work-pulls');rmSync(pw, { recursive: true, force: true }); mkdirSync(pw, { recursive: true });gitIn(pw, 'init', '-q', '-b', 'main');writeFileSync(join(pw, 'a.txt'), 'base\n'); gitIn(pw, 'add', '-A'); gitIn(pw, 'commit', '-q', '-m', 'base');const baseSha = gitIn(pw, 'rev-parse', 'HEAD').trim();const branchCommit = (name, msg) => { gitIn(pw, 'checkout', '-q', '-b', name, baseSha); writeFileSync(join(pw, name.replace(/\//g, '_') + '.txt'), name + '\n'); gitIn(pw, 'add', '-A'); gitIn(pw, 'commit', '-q', '-m', msg); return gitIn(pw, 'rev-parse', 'HEAD').trim(); };gitIn(pw, 'branch', 'dev');const fixSha = branchCommit('fix-a', '|||PR Fix the <b>thing</b>');gitIn(pw, 'commit', '-q', '--allow-empty', '-m', 'more work on fix-a');branchCommit('topic/one', '|||PR|dev] Topic to dev');branchCommit('ghost', '|||PR|nope] Lost target');branchCommit('plain', 'not a pull request |||PR');branchCommit('nospace', '|||PR|main]glued');branchCommit('old', '|||PR Old work');gitIn(pw, 'checkout', '-q', 'main'); gitIn(pw, 'merge', '-q', '--ff-only', 'old');gitIn(pw, 'push', '-q', '-f', join(STORE, 'git', 'beta-2.git'), 'main', 'dev', 'fix-a', 'topic/one', 'ghost', 'plain', 'nospace', 'old');const pullsReady = (p) => p.waitFor('!document.querySelector("#pullsloading") && (!!document.querySelector("#pullsmessage") || !!document.querySelector("#pullshelp"))', { label: 'pulls answer' });await A.goto(REPO('beta-2') + '/pulls');await pullsReady(A);const rows = await A.evaluate(`[...document.querySelectorAll('#pulllist li')].map(li => [li.querySelector('.subject').textContent, li.querySelector('.open,.merged').textContent, li.querySelector('.branches').textContent.replace(/\\s+/g, ' ').trim()].join(' ~ '))`);check('/pulls: one request per marker commit, title without the marker, source → target, state', rows.length === 4 && rows.includes('Fix the <b>thing</b> ~ open ~ fix-a→main') && rows.includes('Topic to dev ~ open ~ topic/one→dev') && rows.includes('Old work ~ merged ~ old→main') && rows.some(r => r.startsWith('Lost target ~ open ~ ghost→nope (no such branch)')), J(rows));check('/pulls: no request from a marker inside the text or without the space after ]; HTML stays text', !rows.some(r => /Not a pull|glued|plain|nospace/.test(r)) && !(await has(A, '#pulllist b')), J(rows));check('/pulls: the title links to the commit, the branches to their pages, the nav link is there', await A.evaluate(`(() => { const a = [...document.querySelectorAll('#pulllist li')].find(li => li.textContent.includes('Fix the')); return a.querySelector('.subject a').href.endsWith('/commit/${fixSha}') && a.querySelector('a.source').pathname === '/branch/fix-a' && a.querySelector('a.target').pathname === '/branch/main'; })() && !!document.querySelector('#navpulls')`));await A.evaluate(`[...document.querySelectorAll('#pulllist a.source')].find(a => a.textContent === 'fix-a').click()`);await A.waitFor('location.pathname === "/branch/fix-a"', { label: 'source branch link' });await S.goto(REPO('beta-2') + '/pulls');await pullsReady(S);check('/pulls: a signed-out viewer sees the same list', (await S.evaluate('document.querySelectorAll("#pulllist li").length')) === 4);await S.goto(REPO('gamma') + '/pulls');await pullsReady(S);check('/pulls of an empty repo says it has no commits yet', /no commits yet/.test(await txt(S, '#pullsmessage') || ''));await S.goto(REPO('alpha') + '/pulls');await pullsReady(S);check('/pulls of alpha lists just the one request from the #N test (merged, "fixes #1" a link)', (await S.evaluate('document.querySelectorAll("#pulllist li").length')) === 1 && !(await has(S, '#nopulls')) && (await S.evaluate('!!document.querySelector("#pulllist li .merged") && !!document.querySelector("#pulllist a.ticketref")')));await S.goto(REPO('nothing-here') + '/pulls');await S.waitFor('!!document.querySelector("#missing")', { label: 'unknown repo pulls' });const forgedPulls = await emitFace('gitoriaPulls', ['beta-2', 'x', 'evil'], '');check('face: a forged session gets no pull list', !forgedPulls.value || forgedPulls.value.ok !== true, J(forgedPulls));for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {await viewport(A, w, h);await A.goto(REPO('beta-2') + '/pulls');await pullsReady(A);check(`layout ${w}px: /pulls has no horizontal overflow`, await noOverflow(A));await shot(A, `${name}-pulls`);}await viewport(A, 1280, 900);// ---- merge button (gitoria#17): the owner merges a pull request, only by clicking; a conflict merges nothing ------const mergeBtns = (p) => p.evaluate(`[...document.querySelectorAll('#pulllist li')].filter(li => li.querySelector('button.mergepr')).map(li => li.querySelector('.subject').textContent).join('|')`);await A.goto(REPO('beta-2') + '/pulls');await pullsReady(A);check('merge: the owner sees Merge on the open requests with a source and a target, not on merged / lost ones', (await mergeBtns(A)) === 'Topic to dev|Fix the <b>thing</b>', await mergeBtns(A));await S.goto(REPO('beta-2') + '/pulls');await pullsReady(S);check('merge: a signed-out viewer sees no Merge button', (await S.evaluate('document.querySelectorAll("button.mergepr").length')) === 0);const forgedMerge = await emitFace('gitoriaMergePull', ['beta-2', fixSha, 'evil'], '');check('merge: a forged session cannot merge', !forgedMerge.value || forgedMerge.value.error != null || forgedMerge.value.result == null, J(forgedMerge));// a branch that conflicts with maingitIn(pw, 'checkout', '-q', '-b', 'clash', baseSha); writeFileSync(join(pw, 'a.txt'), 'clash side\n'); gitIn(pw, 'add', '-A'); gitIn(pw, 'commit', '-q', '-m', '|||PR Clashing change');gitIn(pw, 'checkout', '-q', 'main'); writeFileSync(join(pw, 'a.txt'), 'main side\n'); gitIn(pw, 'add', '-A'); gitIn(pw, 'commit', '-q', '-m', 'main edits a.txt');gitIn(pw, 'push', '-q', '-f', join(STORE, 'git', 'beta-2.git'), 'main', 'clash');const mainBefore = gitIn(join(STORE, 'git', 'beta-2.git'), 'rev-parse', 'main').trim();await A.goto(REPO('beta-2') + '/pulls');await pullsReady(A);const clickMerge = (title) => A.evaluate(`(() => { const li = [...document.querySelectorAll('#pulllist li')].find(l => l.querySelector('.subject').textContent === ${J(title)}); li.querySelector('button.mergepr').click(); })()`);await clickMerge('Clashing change');await A.waitFor('/conflicts/.test((document.querySelector("#mergeerror")||{}).textContent||"")', { label: 'conflict message' }).catch(async e => { throw new Error(e.message + ' ' + await A.evaluate('document.querySelector("#pulllist").innerText + document.querySelector("#mergeerror").outerHTML')); });check('merge: a conflict says so, names the file and merges nothing', /a\.txt/.test(await txt(A, '#mergeerror')) && gitIn(join(STORE, 'git', 'beta-2.git'), 'rev-parse', 'main').trim() === mainBefore && (await A.evaluate('document.querySelectorAll("#pulllist li .open").length')) >= 3);await clickMerge('Fix the <b>thing</b>');await A.waitFor('[...document.querySelectorAll("#pulllist li")].some(l => l.querySelector(".subject").textContent === "Fix the <b>thing</b>" && l.querySelector(".merged"))', { label: 'fix-a merged' }).catch(async e => { throw new Error(e.message + ' ' + await A.evaluate('document.querySelector("#pulllist").innerText + document.querySelector("#mergeerror").textContent')); });const bare = join(STORE, 'git', 'beta-2.git');const mainLog = gitIn(bare, 'log', '--format=%s|%an', 'main');check('merge: the click merges the branch into the target: merge commit by the owner, the branch commits are in main', /Merge branch 'fix-a' into main\|/.test(mainLog) && gitIn(bare, 'merge-base', '--is-ancestor', 'fix-a', 'main') !== null && /more work on fix-a/.test(mainLog) && gitIn(bare, 'show', 'main:fix-a.txt').trim() === 'fix-a', mainLog);check('merge: the other side (main\'s own commit) is kept; the merge commit has two parents', /main edits a\.txt/.test(mainLog) && gitIn(bare, 'rev-list', '--parents', '-n1', 'main').trim().split(' ').length === 3);check('merge: the request now shows merged and its Merge button is gone', !(await A.evaluate(`[...document.querySelectorAll('#pulllist li')].some(l => l.querySelector('.subject').textContent === 'Fix the <b>thing</b>' && l.querySelector('button.mergepr'))`)) && !(await txt(A, '#mergeerror')), J([await txt(A, '#mergeerror'), await A.evaluate(`[...document.querySelectorAll('#pulllist li')].filter(l => l.querySelector('.subject').textContent.startsWith('Fix')).map(l => l.innerHTML).join()`)]));await S.goto(REPO('beta-2') + '/pulls');await pullsReady(S);check('merge: a reload (signed out) shows it merged', await S.evaluate(`[...document.querySelectorAll('#pulllist li')].some(l => l.querySelector('.subject').textContent === 'Fix the <b>thing</b>' && l.querySelector('.merged'))`));await viewport(A, 1280, 900);// ---- releases (gitoria#12): /releases lists the |||RL commits of the main branch, versions counted up ---------const rw = join(STORE, 'work-rel');rmSync(rw, { recursive: true, force: true }); mkdirSync(rw, { recursive: true });gitIn(rw, 'init', '-q', '-b', 'main');const rc = (msg) => { gitIn(rw, 'commit', '-q', '--allow-empty', '-m', msg); return gitIn(rw, 'rev-parse', 'HEAD').trim(); };rc('start');rc('|||RL First <b>one</b>');rc('work');rc('|||RL|med Feature drop');rc('|||RL|mj Big one');rc('|||RL');rc('text |||RL not a release');rc('|||RL|nonsense x');rc('|||RL|1.1.1a By hand');const lastSha = rc('|||RL after the hand one');rc('|||RL|1.1.1a again the same');gitIn(rw, 'checkout', '-q', '-b', 'side'); rc('|||RL on a side branch');gitIn(rw, 'push', '-q', '-f', join(STORE, 'git', 'beta-2.git'), 'main', 'side');const relReady = (p) => p.waitFor('!document.querySelector("#releasesloading") && (!!document.querySelector("#releasesmessage") || !!document.querySelector("#releaseshelp"))', { label: 'releases answer' });await A.goto(REPO('beta-2') + '/releases');await relReady(A);const rel = await A.evaluate(`[...document.querySelectorAll('#releaselist li')].map(li => li.querySelector('.version') && li.querySelector('.subject') ? li.querySelector('.version').textContent + ' ~ ' + li.querySelector('.subject').textContent + (li.querySelector('.latest') ? ' ~ latest' : '') : 'HTML ' + li.innerHTML)`);check('/releases: versions counted up (patch, med, mj, by hand, then on), newest first, latest marked', J(rel) === J(['1.1.2 ~ after the hand one ~ latest', '1.1.1a ~ By hand', '1.0.1 ~ ' + rel[2].split(' ~ ')[1], '1.0.0 ~ Big one', '0.1.0 ~ Feature drop', '0.0.1 ~ First <b>one</b>']), J(rel));check('/releases: no release from a marker inside the text, a bad version, a repeated version or another branch; HTML stays text', rel.length === 6 && !(await has(A, '#releaselist b')), J(rel));check('/releases: a release links to its commit, the nav link is there', await A.evaluate(`document.querySelector('#releaselist li a.subject').href.endsWith('/commit/${lastSha}') && !!document.querySelector('#navreleases')`));await S.goto(REPO('gamma') + '/releases');await relReady(S);check('/releases of an empty repo says it has no commits yet', /no commits yet/.test(await txt(S, '#releasesmessage') || ''));await S.goto(REPO('alpha') + '/releases');await relReady(S);check('/releases of a repo without release commits says "No release yet"', (await has(S, '#noreleases')) && !(await has(S, '#releaselist li')));const forgedRel = await emitFace('gitoriaReleases', ['beta-2', 'x', 'evil'], '');check('face: a forged session gets no release list', !forgedRel.value || forgedRel.value.ok !== true, J(forgedRel));for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {await viewport(A, w, h);await A.goto(REPO('beta-2') + '/releases');await relReady(A);check(`layout ${w}px: /releases has no horizontal overflow`, await noOverflow(A));await shot(A, `${name}-releases`);}await viewport(A, 1280, 900);// ---- gitoria#16: every repo view is its own page, rendered on the SERVER for the request's host ---------------const AH = `alpha.gitoria.test:${PORT}`;// the git views are read on the server too (hl:proc run(), hybriel#80): the FIRST HTML holds the content, no loading stepconst BH = `beta-2.gitoria.test:${PORT}`;const firstViews = [['/', AH, (b, t) => /id="homepage"/.test(b) && /Code repo/.test(t), 'Readme: the README (main branch dev)'],['/code', AH, (b, t) => /id="entries"/.test(b) && /<a class="dir" href="\/code\/src">src<\/a>/.test(b) && /README\.md/.test(t) && /id="coderef">dev</.test(b), 'Code: the file list of the main branch'],['/code/src', AH, (b, t) => /<a class="dir" href="\/code\/src\/deep">deep<\/a>/.test(b) && /a\.txt/.test(t), 'Code/<path>: the folder (the * part reaches the page)'],['/code/src/a.txt', AH, (b, t) => /id="lines"/.test(b) && /line one/.test(t), 'Code/<file>: the file lines'],['/branch/main', AH, (b, t) => /id="coderef">main</.test(b) && /id="entries"/.test(b) && /second on main/.test(t), 'Branch main: its tree and last commit'],['/branch/feature/x', AH, (b, t) => /id="coderef">feature\/x</.test(b) && /only-feature\.txt/.test(t), 'Branch with a slash'],['/commit/' + firstSha, AH, (b, t) => /id="codekind"[^>]*>Commit</.test(b) && /id="entries"/.test(b) && /first commit/.test(t), 'Commit: the project at that commit'],['/pulls', BH, (b, t) => /id="pulllist"/.test(b) && /Fix the/.test(t), 'Pull requests: the list'],['/releases', BH, (b, t) => /id="releaselist"/.test(b) && /1\.1\.2/.test(t) && /after the hand one/.test(t), 'Releases: the list'],];for (const [path, host, ok, what] of firstViews) {const f = await firstHtml(path, host);const t = bodyText(f.body);check(`first HTML of ${host.split('.')[0]}${path}: ${what}, no "Loading"`, f.status === 200 && /id="reponame"/.test(f.body) && ok(f.body, t) && !/Loading/i.test(t), t.slice(0, 400));}r = await fetch(API + '/host.js');check('/host.js is gone (404)', r.status === 404, String(r.status));let fh, ft;// (the tickets copy was restarted empty above) one ticket made in tickets itself, then the page from scratch// tickets #20: a ticket needs an existing project, made by its first admin (as gitoria does)await fetch(tickets.base + '/api/projects', { method: 'POST', headers: { authorization: 'Bearer ' + tickets.token, 'content-type': 'application/json' }, body: J({ title: 'alpha.gitoria.test', slug: 'alpha.gitoria.test' }) });await fetch(tickets.base + '/api/projects/alpha.gitoria.test/tickets', { method: 'POST', headers: { authorization: 'Bearer ' + tickets.token, 'content-type': 'application/json' }, body: J({ subject: 'From tickets itself' }) });fh = await firstHtml('/tickets', AH);ft = bodyText(fh.body);check('first HTML of alpha/tickets: the ticket list itself is there (no loading step)', /id="ticketlist"/.test(fh.body) && /From tickets itself/.test(ft) && !/Loading/.test(ft), ft.slice(0, 400));fh = await firstHtml('/', `gitoria.test:${PORT}`);ft = bodyText(fh.body);check('first HTML of the main address: the repo list with every repo (no loading step)', /<h1 id="heading">Repositories<\/h1>/.test(fh.body) && /alpha/.test(ft) && /beta-2/.test(ft) && /gamma/.test(ft) && !/Loading/.test(ft), ft.slice(0, 300));check('first HTML: the hostile description of gamma stays inside the seed string (no second script, no <b id=xss>) — hybriel#34 upstream', !/<b id="xss">/.test(fh.body) && (fh.body.match(/<script\b/g) || []).length === (fh.body.match(/<\/script>/g) || []).length && fh.body.includes('\\u003c/script>'), (fh.body.match(/xss[^,]{0,80}/) || [''])[0]);let allMissing = true, missDetail = '';for (const p of ['/', '/code', '/code/src', '/branch/main', '/commit/abcdef1', '/pulls', '/releases', '/tickets']) {const m = await firstHtml(p, `nothing-here.gitoria.test:${PORT}`);if (m.status !== 200 || !/<h1 id="missing">No such repository<\/h1>/.test(m.body) || /id="reponame"/.test(m.body)) { allMissing = false; missDetail += ' ' + p + ':' + m.status; }}check('unknown repo: every view says "No such repository" in the first HTML', allMissing, missDetail);// direct load of every view in Chromeconst views = [['/', '#homepage, #nohomepage', 'Readme'], ['/code', '#entries', 'Code'], ['/code/src/a.txt', '#lines', 'Code'], ['/branch/feature/x', '#entries', 'Branch'],['/commit/' + firstSha.slice(0, 8), '#entries', 'Commit'], ['/pulls', '#pullshelp', 'Pull requests'], ['/releases', '#releaseshelp', 'Releases'], ['/tickets', '#ticketlist', 'Tickets'],];let direct = true, directDetail = '';for (const [p, sel, title] of views) {await A.goto(REPO('alpha') + p);try { await A.waitFor(`!!document.querySelector(${J(sel)}) && !!document.querySelector('#reponame')`, { label: 'direct ' + p, timeout: 6000 }); } catch { direct = false; directDetail += ' ' + p + ' (no ' + sel + ')'; continue; }const t = await A.evaluate('document.title');if (!t.startsWith(title === 'Readme' ? 'alpha' : title) || !t.includes('alpha')) { direct = false; directDetail += ` ${p} title ${J(t)}`; }}check('direct load of every view (Readme, Code, a file, Branch, Commit, Pulls, Releases, Tickets): its content, its tab title', direct, directDetail);// hl:web navigation: Readme → Code → Releases → Tickets → Pulls → Readme without a page loadawait A.goto(REPO('alpha') + '/');await A.waitFor('!!document.querySelector("#homepage, #nohomepage")', { label: 'nav start: Readme' });await hydrated(A);await A.evaluate('window.__navMarker = 42');const navSteps = [['#navcode', '/code', '#entries'], ['#navreleases', '/releases', '#releaseshelp'], ['#navtickets', '/tickets', '#ticketlist'], ['#navpulls', '/pulls', '#pullshelp'], ['#navhome', '/', '#homepage, #nohomepage']];let navOk = true, navDetail = '';for (const [link, path, sel] of navSteps) {await A.click(link);try { await A.waitFor(`location.pathname === ${J(path)} && !!document.querySelector(${J(sel)})`, { label: 'nav to ' + path, timeout: 6000 }); } catch { navOk = false; navDetail += ` ${path}: ${await A.evaluate('location.pathname + " " + document.body.innerText.slice(0, 120)')}`; continue; }if ((await A.evaluate('window.__navMarker')) !== 42) { navOk = false; navDetail += ` ${path}: page reloaded`; }if ((await txt(A, '#reponame')) !== 'alpha' || (await A.evaluate('location.host')) !== AH) { navOk = false; navDetail += ` ${path}: wrong repo/host`; }}check('navigation Readme → Code → Releases → Tickets → Pulls → Readme: no full page load (window marker survives), the address bar follows, content right', navOk, navDetail);await A.click('#navcode');await A.waitFor('location.pathname === "/code" && !!document.querySelector("#entries a.dir")', { label: 'code for folder nav' });await A.click('#entries a.dir');await A.waitFor('location.pathname === "/code/src" && !!document.querySelector("#crumbs strong") && document.querySelector("#crumbs strong").textContent === "src"', { label: 'folder via nav' });check('navigation inside Code (a folder) keeps the page too, and the browser Back button goes back to /code', (await A.evaluate('window.__navMarker')) === 42 && await (async () => { await A.evaluate('history.back()'); await A.waitFor('location.pathname === "/code" && [...document.querySelectorAll("#entries a")].some(x => x.textContent === "src")', { label: 'back to /code' }); return (await A.evaluate('window.__navMarker')) === 42; })());// NAVIGATION WHILE LOGGED IN (the creator saw full page loads): a fresh browser, logged in through the button on the// repo address like a person, then Readme → Code → Releases → Pulls → Tickets → Code → a folder with real clicksconst L = await newPage();{ const [lk, lv] = alice.cookie.split('='); await L.send('Network.enable'); await L.send('Network.setCookie', { name: lk, value: lv, url: IDENT_B + '/' }); }await L.goto(REPO('alpha') + '/');await L.waitFor('!!document.querySelector("#loginbutton") && !!document.querySelector("#reponame")', { label: 'L: repo, signed out' });await hydrated(L);await buttonLogin(L);await L.waitFor(`location.href === ${J(REPO('alpha') + '/')} && !!document.querySelector("#whoami") && !!document.querySelector("#homepage")`, { label: 'L: back logged in' });await hydrated(L);await L.evaluate('window.__navMarker = 99');// the folder step waits for '#crumbs a' (only inside a folder): '#crumbs strong' is already on /code, so it passed with /code/src still in flightconst loggedSteps = [['#navhome', '/', '#homepage'], ['#navcode', '/code', '#entries'], ['#navreleases', '/releases', '#releaseshelp'], ['#navpulls', '/pulls', '#pullshelp'], ['#navtickets', '/tickets', '#ticketlist'], ['#navcode', '/code', '#entries'], ['#entries a.dir', '/code/src', '#crumbs a']];let liOk = true, liDetail = '';for (const [link, path, sel] of loggedSteps) {await L.click(link);try { await L.waitFor(`location.pathname === ${J(path)} && !!document.querySelector(${J(sel)}) && !!document.querySelector('#whoami')`, { label: 'L nav ' + path, timeout: 6000 }); } catch { liOk = false; liDetail += ` ${path}: ${await L.evaluate('location.pathname + " " + document.body.innerText.slice(0, 120)')}`; continue; }if ((await L.evaluate('window.__navMarker')) !== 99) { liOk = false; liDetail += ` ${path}: FULL PAGE LOAD`; await L.evaluate('window.__navMarker = 99'); await hydrated(L); }if ((await txt(L, '#reponame')) !== 'alpha' || (await txt(L, '#whoami')) !== 'alice') { liOk = false; liDetail += ` ${path}: wrong repo/user`; }}check('navigation LOGGED IN (button login on the repo address): Readme → Code → Releases → Pulls → Tickets → Code → a folder, real clicks: no full page load, content right, still logged in', liOk, liDetail);// the same on an EMPTY repo the user owns (like the creator's live repos: no commit yet)const emptySteps = [['#navcode', '/code', '#codemessage'], ['#navreleases', '/releases', '#releasesmessage'], ['#navpulls', '/pulls', '#pullsmessage'], ['#navtickets', '/tickets', '#ticketsnotconnected'], ['#navhome', '/', '#nohomepage']];await L.goto(REPO('gamma') + '/');await L.waitFor('!!document.querySelector("#nohomepage") && !!document.querySelector("#whoami")', { label: 'L: gamma' });await hydrated(L);await L.evaluate('window.__navMarker = 98');let emOk = true, emDetail = '';for (const [link, path, sel] of emptySteps) {await L.click(link);try { await L.waitFor(`location.pathname === ${J(path)} && !!document.querySelector(${J(sel)}) && !!document.querySelector('#whoami')`, { label: 'L gamma ' + path, timeout: 6000 }); } catch { emOk = false; emDetail += ` ${path}: ${await L.evaluate('location.pathname + " " + document.body.innerText.slice(0, 120)')}`; continue; }if ((await L.evaluate('window.__navMarker')) !== 98) { emOk = false; emDetail += ` ${path}: FULL PAGE LOAD`; await L.evaluate('window.__navMarker = 98'); await hydrated(L); }}check('navigation LOGGED IN on an empty repo the user owns: Code → Releases → Pulls → Tickets → Readme, no full page load', emOk, emDetail);// the socket gone (Cloudflare closes an idle WebSocket after ~100 s): navigation rides the POST fallbackawait L.goto(REPO('alpha') + '/');await L.waitFor('!!document.querySelector("#homepage") && !!document.querySelector("#whoami")', { label: 'L: alpha again' });await hydrated(L);await L.evaluate('window.__hl.socket.close(); window.__navMarker = 97');await L.waitFor('!window.__hl.socket', { label: 'L: socket gone' });let pfOk = true, pfDetail = '';for (const [link, path, sel] of [['#navcode', '/code', '#entries'], ['#navreleases', '/releases', '#releaseshelp'], ['#navhome', '/', '#homepage']]) {await L.click(link);try { await L.waitFor(`location.pathname === ${J(path)} && !!document.querySelector(${J(sel)}) && !!document.querySelector('#whoami')`, { label: 'L post ' + path, timeout: 6000 }); } catch { pfOk = false; pfDetail += ` ${path}: timeout`; continue; }if ((await L.evaluate('window.__navMarker')) !== 97) { pfOk = false; pfDetail += ` ${path}: FULL PAGE LOAD`; break; }}check('navigation LOGGED IN with the socket closed (POST fallback): no full page load, still logged in', pfOk, pfDetail);// THE SAME IN A REAL FIREFOX (the creator's browser; tests/firefox.mjs, WebDriver BiDi, real pointer clicks)ff = await launchFirefox({ port: FIREFOX_PORT, hosts: ['gitoria.test', 'alpha.gitoria.test', 'beta-2.gitoria.test', 'gamma.gitoria.test', 'ident.gitoria.test'] });{ const [fk, fv] = alice.cookie.split('='); await ff.setCookie(fk, fv, 'ident.gitoria.test'); }const ffLive = () => ff.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'firefox: page hydrated' });await ff.goto(REPO('alpha') + '/');await ff.waitFor('!!document.querySelector("#loginbutton") && !!document.querySelector("#reponame")', { label: 'firefox: repo, signed out' });await ffLive();await ff.click('#loginbutton');await ff.waitFor('!!document.querySelector("#chooselist li .choose")', { label: 'firefox: ident chooser' });await ffLive();await ff.click('#chooselist li:nth-child(1) .choose');await ff.waitFor(`location.href === ${J(REPO('alpha') + '/')} && !!document.querySelector("#whoami") && !!document.querySelector("#homepage")`, { label: 'firefox: back logged in', timeout: 30000 });await ffLive();await ff.evaluate('window.__navMarker = 99');let ffOk = true, ffDetail = '';for (const [link, path, sel] of loggedSteps) {await ff.click(link);try { await ff.waitFor(`location.pathname === ${J(path)} && !!document.querySelector(${J(sel)}) && !!document.querySelector('#whoami')`, { label: 'firefox nav ' + path, timeout: 8000 }); } catch { ffOk = false; ffDetail += ` ${path}: ${await ff.evaluate('location.pathname + " " + document.body.innerText.slice(0, 120)')}`; continue; }if ((await ff.evaluate('window.__navMarker')) !== 99) { ffOk = false; ffDetail += ` ${path}: FULL PAGE LOAD`; await ff.evaluate('window.__navMarker = 99'); await ffLive(); }if ((await ff.evaluate('document.querySelector("#reponame").textContent + "/" + document.querySelector("#whoami").textContent')) !== 'alpha/alice') { ffOk = false; ffDetail += ` ${path}: wrong repo/user`; }}check('FIREFOX, navigation LOGGED IN: Readme → Code → Releases → Pulls → Tickets → Code → a folder, real clicks: no full page load, content right, still logged in', ffOk, ffDetail);await ff.goto(REPO('gamma') + '/');await ff.waitFor('!!document.querySelector("#nohomepage") && !!document.querySelector("#whoami")', { label: 'firefox: gamma' });await ffLive();await ff.evaluate('window.__navMarker = 98');let ffeOk = true, ffeDetail = '';for (const [link, path, sel] of emptySteps) {await ff.click(link);try { await ff.waitFor(`location.pathname === ${J(path)} && !!document.querySelector(${J(sel)}) && !!document.querySelector('#whoami')`, { label: 'firefox gamma ' + path, timeout: 8000 }); } catch { ffeOk = false; ffeDetail += ` ${path}: ${await ff.evaluate('location.pathname + " " + document.body.innerText.slice(0, 120)')}`; continue; }if ((await ff.evaluate('window.__navMarker')) !== 98) { ffeOk = false; ffeDetail += ` ${path}: FULL PAGE LOAD`; await ff.evaluate('window.__navMarker = 98'); await ffLive(); }}check('FIREFOX, navigation LOGGED IN on an empty repo the user owns, no full page load', ffeOk, ffeDetail);check('FIREFOX: no console errors', ff.errors.length === 0, ff.errors.join(' | '));await ff.close(); ff = null;await A.goto(BASE + '/');await A.waitFor('document.querySelectorAll("#repos li").length === 3', { label: 'list with gamma' });check('the hostile description shows as text on the list, nothing of it ran (hybriel#34 upstream)', (await A.evaluate('!window.__xss && !document.querySelector("#xss")')) && (await txt(A, '#repos')).includes(XSS), await txt(A, '#repos'));// ---- layout ------------------------------------------------------------------------------------------------for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {await viewport(A, w, h);await A.goto(BASE + '/');await A.waitFor('document.querySelectorAll("#repos li").length === 3', { label: 'list for layout' });check(`layout ${w}px: main address has no horizontal overflow`, await noOverflow(A));await shot(A, `${name}-main`);await A.goto(REPO('alpha') + '/');await A.waitFor('!!document.querySelector("#reponame")');check(`layout ${w}px: repo address has no horizontal overflow`, await noOverflow(A));await shot(A, `${name}-repo`);}// ---- the identity selector (gitoria#14): choose an identity on the main address, no reload ------------------------const C = await newPage();// alice is already signed in to ident (over REST, ident allows only 3 codes per address): hand C that ident sessionconst [ck, cv] = alice.cookie.split('=');await C.send('Network.enable');await C.send('Network.setCookie', { name: ck, value: cv, url: IDENT_B + '/' });await C.goto(BASE + '/');await C.waitFor('!!document.querySelector("#heading")', { label: 'C: main address' });await hydrated(C);await C.evaluate('window.__loginMarker = 1');// hybriel#43: a second tab of the SAME browser (same session) on a code view — the login/logout flip of the shell// must leave the page in the slot alone (no re-creation, no "Loading")const C2 = patient(await browsers[browsers.length - 1].newPage());await C2.goto(REPO('alpha') + '/code');await C2.waitFor('!!document.querySelector("#entries") && !!document.querySelector("#loginbutton")', { label: 'C2: code view' });await hydrated(C2);await C2.evaluate('window.__flipMarker = 7; document.querySelector("#entries").dataset.keep = "1"');const codeKept = async () => C2.evaluate('window.__flipMarker === 7 && !!document.querySelector("#entries[data-keep]") && [...document.querySelectorAll("#entries a")].some(a => a.textContent === "src") && !/Loading/i.test(document.body.innerText)');check('selector: signed out, ident\'s "choose ident" sits beside the login button', await C.evaluate(`(() => { const s = document.querySelector('#selector'); const r = s && s.shadowRoot; return !!r && /choose/.test(r.querySelector('#choose').textContent) && !s.hasAttribute('logged-in') && !!document.querySelector('#loginbutton'); })()`));await shClick(C, '#choose');await C.waitFor(sh(`r.querySelectorAll('[part~="identity"]').length > 0`), { label: 'selector list' });await shClick(C, '[part~="identity"]', 'Default');await C.waitFor('!!document.querySelector("#logout")', { label: 'C: logged in after the selector login' });check('selector login: no reload, logged in, the button is gone, the selector says so', (await C.evaluate('window.__loginMarker')) === 1 && await has(C, '#logout') && !(await has(C, '#loginbutton')) && await C.evaluate('document.querySelector("#selector").hasAttribute("logged-in")') && !(await has(C, '#loginerror')));await C.waitFor('!!document.querySelector("#createform")', { label: 'C: create form after the selector login' });check('selector login: the page follows (create form), the same session as after the button', await has(C, '#createform') && (await txt(C, '#whoami')) === 'alice');await C2.waitFor('!!document.querySelector("#whoami")', { label: 'C2: logged in by the other tab' });check('hybriel#43: a code view in another tab of the session flips to logged in, the code stays (same elements, no "Loading", no reload)', await codeKept(), await C2.evaluate('document.body.innerText.slice(0, 300)'));await C.click('#logout');await C.waitFor('!!document.querySelector("#loginbutton")', { label: 'C: logged out' });await C.waitFor(sh(`/choose/.test(r.querySelector('#choose').textContent)`), { label: 'selector reset' });await C2.waitFor('!!document.querySelector("#loginbutton")', { label: 'C2: logged out by the other tab' });check('hybriel#43: … and back to logged out, the code still stays', await codeKept(), await C2.evaluate('document.body.innerText.slice(0, 300)'));check('selector: logout resets it to "choose ident"', await C.evaluate('!document.querySelector("#selector").hasAttribute("logged-in")') && (await C.evaluate('document.querySelectorAll("ident-selector").length')) === 1);await C.goto(REPO('alpha') + '/');await C.waitFor('!!document.querySelector("#loginbutton") && !!document.querySelector("#reponame")', { label: 'C: repo address' });await hydrated(C);const repoSel = await firstHtml('/', `alpha.gitoria.test:${PORT}`);check('repo address: the selector is hidden (class onrepo from the server\'s host, ident knows only the main origin), the button stays', /<ident-selector[^>]*class="out onrepo"/.test(repoSel.body) && await C.evaluate('getComputedStyle(document.querySelector("#selector")).display === "none"') && await has(C, '#loginbutton'));const loginFx = await fetch(API + '/login.js');check('/login.js is served', loginFx.status === 200 && /ident-login/.test(await loginFx.text()));// ---- logout, then the data survives a restart --------------------------------------------------------------await A.goto(BASE + '/');await A.waitForSelector('#logout');await hydrated(A);await A.click('#logout');await A.waitFor('!!document.querySelector("#loginbutton") && !document.querySelector("#createform")', { label: 'logged out' });await A.waitFor('!!document.querySelector("#loginhint")', { label: 'A: list after the logout' });check('logout: the button and the create form switch without a reload', await has(A, '#loginhint'), await A.evaluate('document.body.innerText.slice(0, 300)'));await stopServer();startServer(env);await serverUp();const again = await (await fetch(API + '/api/repos')).json();check('restart: all repos are still there (storage/mpackdb/repos.db)', again.repos.length === 3 && again.repos.map(x => x.slug).join() === 'gamma,beta-2,alpha', J(again));check('storage: the tables are in <store>/mpackdb/', ['repos', 'users'].every(t => readdirSync(join(STORE, 'mpackdb')).some(n => n.startsWith(t + '.'))), J(readdirSync(join(STORE, 'mpackdb'))));const problems = [A, B, C, C2, S, L].flatMap(p => p.problems().filter(m => !/favicon|ERR_|Failed to load resource/.test(m.text)));check('browsers: no console errors', problems.length === 0, problems.map(m => m.text).join(" | "));check('server log: no error other than absorbed ones', !/error(?!: absorbed)/i.test(log.replace(/error absorbed[^\n]*/g, '')), log.split('\n').filter(l => /error/i.test(l)).slice(0, 5).join(" | "));// ==== mission 046: THE INSTALLABLE APP — hl:web's own manifest + service worker (project.hl appIcons / offline =// [ Index ]). Last, so every check above ran on the gate's usual Chromes: those are closed now and ONE fresh Chrome// treats the two test origins as secure (a service worker needs a secure context; plain-http *.gitoria.test is not// one — https / localhost are, so the live site needs no flag). The manifest and every icon over HTTP; Chrome's own// verdict; the worker registered and controlling; then OFFLINE: the tab loses the network (the note appears), the// server is stopped too (CDP's offline emulation does not reach the worker's own fetches — measured in tracker's// gate — so only a dead server proves the cache), and `/` still opens from the cache: header + note + the list as// last seen. A data page gets hl:web's "Unavailable offline" page. The same on a repo address (its own origin).const html = await (await fetch(API + '/')).text();const mhref = (html.match(/<link rel="manifest" href="([^"]+)"/) || [])[1];check('pwa: every page head links the manifest, the apple-touch-icon, the favicon and the theme colour (the token `darker`)',mhref === '/__hl/manifest.webmanifest' && html.includes('<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">')&& html.includes('<link rel="icon" href="/icons/favicon.svg">') && html.includes('<meta name="theme-color" content="rgb(15, 20, 25)">'), html.slice(0, 900));const mres = await fetch(API + mhref);const man = await mres.json();check('pwa: the manifest is served as application/manifest+json: name "gitoria", start_url /, scope /, display standalone, the token colours',/manifest\+json/.test(mres.headers.get('content-type') || '') && man.name === 'gitoria' && man.short_name === 'gitoria' && man.start_url === '/' && man.scope === '/'&& man.display === 'standalone' && man.theme_color === 'rgb(15, 20, 25)' && man.background_color === 'rgb(25, 30, 35)', J(man));const iconOk = [];for (const ic of man.icons || []) {const ir = await fetch(API + ic.src);const b = Buffer.from(await ir.arrayBuffer());iconOk.push(ir.ok && ic.type === 'image/png' && b.readUInt32BE(16) === Number(ic.sizes.split('x')[0]) && b.readUInt32BE(20) === Number(ic.sizes.split('x')[1]));}check('pwa: every manifest icon loads and is a real PNG of its declared size (192 + 512, any + maskable)',iconOk.length === 4 && iconOk.every(Boolean) && ['any', 'maskable'].every(pu => ['192x192', '512x512'].every(sz => man.icons.some(i => i.purpose === pu && i.sizes === sz))), J(man.icons) + ' ' + J(iconOk));const touch = await fetch(API + '/icons/apple-touch-icon.png');const touchBytes = Buffer.from(await touch.arrayBuffer());const fav = await fetch(API + '/icons/favicon.svg');const favText = await fav.text();const ico = await fetch(API + '/favicon.ico');const icoBytes = Buffer.from(await ico.arrayBuffer());check('pwa: apple-touch-icon is a 180×180 PNG, the favicon an SVG in gitoria blue, /favicon.ico a real icon file',touch.ok && touchBytes.readUInt32BE(16) === 180 && fav.ok && /image\/svg\+xml/.test(fav.headers.get('content-type') || '') && favText.includes('#569bd4')&& ico.ok && icoBytes.readUInt32BE(0) === 0x00000100, `${touch.status} ${fav.status} ${ico.status}`);const repoMan = await (await fetch(API + '/__hl/manifest.webmanifest', { headers: { Host: `alpha.gitoria.test:${PORT}` } })).json();check('pwa: a repo address serves the same manifest (its own origin, start_url / = its Readme)', repoMan.name === 'gitoria' && repoMan.start_url === '/', J(repoMan));for (const b of browsers) { try { await b.close(); } catch {} }browsers.length = 0;process.env.HL_CHROME_ARGS += `|--unsafely-treat-insecure-origin-as-secure=${BASE},${REPO('alpha')}`;const P = await newPage();const pev = (e) => P.evaluate(e);await P.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });const workerOn = async (label) => {await hydrated(P);await P.waitFor(`navigator.serviceWorker.getRegistration().then(r => !!(r && r.active))`, { timeout: 15000, label: label + ': service worker active' });await P.waitFor(`!!navigator.serviceWorker.controller`, { timeout: 10000, label: label + ': page controlled by the worker' });};await P.goto(BASE + '/');await P.waitFor('document.querySelectorAll("#repos li").length === 3', { label: 'pwa: list' });await workerOn('main');const inst = await P.send('Page.getInstallabilityErrors');check('pwa: Chrome reports no installability error', (inst.installabilityErrors || []).length === 0, J(inst));const am = await P.send('Page.getAppManifest');check('pwa: Chrome parses the manifest without errors', am.url && am.url.endsWith('/__hl/manifest.webmanifest') && (am.errors || []).length === 0, J(am.errors));check('pwa: hl:web\'s service worker is registered for the whole app (scope /) and controls the page',(await pev(`navigator.serviceWorker.getRegistration().then(r => new URL(r.scope).pathname + ' ' + new URL(r.active.scriptURL).pathname)`)) === '/ /__hl/sw.js' && (await pev('!!navigator.serviceWorker.controller')));check('pwa: online, no offline note', !(await has(P, '#offline')));await shot(P, 'pwa-phone-online');// a repo address: its own origin → its own worker, `/` (the Readme) kept there tooawait P.goto(REPO('alpha') + '/');await P.waitFor('!!document.querySelector("#reponame")', { label: 'pwa: alpha readme' });await workerOn('alpha');check('pwa: a repo address registers its own worker too (scope /)', (await pev(`navigator.serviceWorker.getRegistration().then(r => location.host + ' ' + new URL(r.scope).pathname)`)) === `alpha.gitoria.test:${PORT} /`);await P.goto(BASE + '/');await P.waitFor('document.querySelectorAll("#repos li").length === 3', { label: 'pwa: list again' });await hydrated(P);await P.setOffline(true);await P.waitFor(`!!document.querySelector('#offline')`, { timeout: 5000, label: 'offline note (live)' }).catch(() => {});check('offline: the open page says so within seconds (the shell\'s tick)', (await txt(P, '#offline')) === 'You are offline. Repositories and code need the network.');await stopServer();let down = false;try { await fetch(API + '/api/repos'); } catch { down = true; }await P.goto(BASE + '/');await P.waitFor(`!!document.querySelector('#offline')`, { timeout: 8000, label: 'offline note after reload' }).catch(() => {});check('offline: with the server gone too, a reload of / still opens the shell from the worker\'s cache — header, brand, the note, the list as last seen',down && (await txt(P, 'application-header .brand')) === 'gitoria' && await has(P, '#offline')&& (await pev('document.querySelectorAll("#repos li").length')) === 3&& (await pev(`getComputedStyle(document.querySelector('application-header')).backgroundColor`)) === 'rgb(15, 20, 25)', 'server down: ' + down + ' ' + (await pev('document.body.innerText.slice(0, 200)')));await shot(P, 'pwa-phone-offline');await P.goto(REPO('alpha') + '/');await P.waitFor(`!!document.querySelector('#offline')`, { timeout: 8000, label: 'offline note on alpha' }).catch(() => {});check('offline: a repo address\'s / (the Readme) opens from its own cache, with the note', (await txt(P, '#reponame')) !== null && await has(P, '#offline'), await pev('document.body.innerText.slice(0, 200)'));await P.goto(REPO('alpha') + '/code');check('offline: a data page (/code) is not kept — hl:web\'s "Unavailable offline" page', (await pev(`!!document.querySelector('main[data-hl-offline]') && document.querySelector('h1').textContent`)) === 'Unavailable offline');await P.setOffline(false);startServer(env);await serverUp();await P.goto(BASE + '/');await hydrated(P);await P.waitFor(`!document.querySelector('#offline')`, { timeout: 5000, label: 'note gone' }).catch(() => {});check('back online: / from the server again, no offline note', !(await has(P, '#offline')) && (await pev('document.querySelectorAll("#repos li").length')) === 3);} catch (e) {failures++;console.log('FAIL gate crashed — ' + (e && e.stack || e));} finally {for (const b of browsers) { try { await b.close(); } catch {} }if (ff) { try { await ff.close(); } catch {} }await stopServer();if (tickets) await tickets.stop();if (ident) await ident.stop();writeFileSync(join(SCRATCH, 'gate-server.log'), log);console.log(`${passes} passed, ${failures} failed`);process.exit(failures ? 1 : 0);}
Branches
- mainmain branch
Latest commits
- 09ce4f3fgitoria: mission 069 re-vendor hybriel 8efba065 stopped (big SSR pages grow + slow down); lambda audit clean; old vendor keptmre
- 3dc43108antcolony#40: mission references point to the moved missionsmre
- 8d9450fdantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 205d5fe4gitoria: Hybriel master ff51cf46; ssh keys/tokens no double rows (session sync); gates follow #20mre
- 9b27cb26gitoria#21: installable app (manifest, service worker, offline start page), own iconmre
- 68dcb603deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- e2deed6dgitoria#20: "Add code" only on the Code page of an empty repository, no collapsiblemre
- 8bb97ffddeploy.sh: never send .git or .gitignore to Byrodinmre
- fd981932State of 2026-09-27; bin/ no longer tracked (Hybriel commit is in README)mre
- 4a2d7125initial commitmre