gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit09ce4f3f09ce4f3fgitoria: mission 069 re-vendor hybriel 8efba065 stopped (big SSR pages grow + slow down); lambda audit clean; old vendor keptmre09ce4f3f/tests/browser.mjs

95.1 KB

  1. // tests/browser.mjs — THE GATE of gitoria.worldapi.org (ticket #6: repos with their own address).
  2. // Its own gitoria server on its own storage, its OWN ident (a copy of ident's code without .env —
  3. // codes go to a mail sink, tests/identkit.mjs), real headless Chromes for everything visible.
  4. // `*.gitoria.test` is mapped to 127.0.0.1 inside Chrome (--host-resolver-rules), so a repo really
  5. // lives at http://<slug>.gitoria.test:<port>/ — the same mechanism as <slug>.gitoria.worldapi.org.
  6. //
  7. // node tests/browser.mjs (GITORIA_GATE_PORT server, default 8700; GITORIA_GATE_IDENT_PORT, default 8701;
  8. // GITORIA_GATE_CHROME "8702-8709" = Chrome debug ports)
  9. // Screenshots (390 / 1280 px) land in .scratch/gate-*.png. Every process started is stopped by PID.
  10. import { spawn, execFileSync } from 'node:child_process';
  11. import { request as httpRequest } from 'node:http';
  12. import { rmSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
  13. import { dirname, join, resolve } from 'node:path';
  14. import { fileURLToPath } from 'node:url';
  15. import { launchBrowser } from './cdp.mjs';
  16. import { launchFirefox } from './firefox.mjs';
  17. import { startIdent } from './identkit.mjs';
  18. import { startTickets } from './ticketskit.mjs';
  19. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  20. const HERE = dirname(fileURLToPath(import.meta.url));
  21. const APP = resolve(HERE, '..');
  22. const BIN = join(APP, 'bin/hybriel');
  23. const PORT = Number(process.env.GITORIA_GATE_PORT || 8700);
  24. const IDENT_PORT = Number(process.env.GITORIA_GATE_IDENT_PORT || 8701);
  25. const API = `http://127.0.0.1:${PORT}`; // node's view of the server
  26. const BASE = `http://gitoria.test:${PORT}`; // the browser's view: the main address
  27. // ident as the BROWSER sees it: same site as gitoria.test (the selector's fetch carries ident's Lax cookie only same-site)
  28. const IDENT_B = `http://ident.gitoria.test:${IDENT_PORT}`;
  29. const REPO = (slug) => `http://${slug}.gitoria.test:${PORT}`;
  30. const TICKETS_PORT = Number(process.env.GITORIA_GATE_TICKETS_PORT || 8702);
  31. const FIREFOX_PORT = Number(process.env.GITORIA_GATE_FIREFOX || 8699); // WebDriver BiDi port of the gate's Firefox
  32. const [CH_FROM, CH_TO] = (process.env.GITORIA_GATE_CHROME || '8703-8709').split('-').map(Number);
  33. process.env.HL_CHROME_ARGS = '--host-resolver-rules=MAP *.gitoria.test 127.0.0.1, MAP gitoria.test 127.0.0.1';
  34. const SCRATCH = join(APP, '.scratch');
  35. const STORE = join(SCRATCH, 'gate-store');
  36. const ORIGINS = ['alpha', 'beta-2', 'gamma'].map(x => `http://${x}.gitoria.test:${PORT}`).join(',');
  37. 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')));
  38. 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')));
  39. rmSync(STORE, { recursive: true, force: true });
  40. mkdirSync(STORE, { recursive: true });
  41. let failures = 0, passes = 0;
  42. function check(label, ok, detail = '') {
  43. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  44. if (ok) passes++; else failures++;
  45. }
  46. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  47. const J = JSON.stringify;
  48. const XSS = '</script><b id="xss">x</b><script>window.__xss=1</script>';
  49. let log = '';
  50. let server = null, ident = null, tickets = null, ff = null;
  51. const browsers = [];
  52. function startServer(extraEnv = {}) {
  53. log += '\n==== gitoria server start\n';
  54. server = spawn(BIN, ['project.hl'], {
  55. cwd: APP,
  56. 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'),
  57. GITORIA_PUBLIC_URL: BASE, IDENT_URL: IDENT_B, IDENT_EXCHANGE_URL: ident.base, ...extraEnv },
  58. stdio: ['ignore', 'pipe', 'pipe'],
  59. });
  60. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  61. }
  62. async function serverUp() {
  63. for (let i = 0; i < 80; i++) { try { const r = await fetch(API + '/api/repos'); if (r.ok) return; } catch {} await sleep(250); }
  64. throw new Error('gitoria did not come up\n' + log);
  65. }
  66. async function stopServer() {
  67. if (!server) return;
  68. try { server.kill('SIGTERM'); } catch {}
  69. await new Promise(r => { if (server.exitCode !== null || server.signalCode !== null) return r(); server.once('exit', r); setTimeout(r, 3000); });
  70. server = null;
  71. }
  72. const SLOW = Number(process.env.GITORIA_GATE_SLOW || 3);
  73. function patient(page) {
  74. const waitFor = page.waitFor.bind(page);
  75. page.waitFor = (expr, o = {}) => waitFor(expr, { ...o, timeout: (o.timeout || 10000) * SLOW });
  76. const goto = page.goto.bind(page);
  77. page.goto = (url, o = {}) => goto(url, { ...o, timeout: (o.timeout || 15000) * SLOW });
  78. return page;
  79. }
  80. async function newPage() {
  81. const b = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });
  82. browsers.push(b);
  83. return patient(await b.newPage());
  84. }
  85. async function viewport(page, width, height) {
  86. await page.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 });
  87. await sleep(250);
  88. }
  89. async function shot(page, name) {
  90. const { data } = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
  91. writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64'));
  92. }
  93. const noOverflow = (page) => page.evaluate('document.documentElement.scrollWidth <= window.innerWidth');
  94. const hydrated = (page) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'page hydrated' });
  95. const txt = (page, sel) => page.evaluate(`(document.querySelector(${J(sel)}) || {}).textContent || null`);
  96. const has = (page, sel) => page.evaluate(`!!document.querySelector(${J(sel)})`);
  97. // INSIDE ident's selector (shadow DOM): an expression over its root `r`, and a REAL click (as in tickets' gate)
  98. const sh = (expr) => `(() => { const h = document.querySelector('#selector'); const r = h && h.shadowRoot; if (!r) return null; return (${expr}); })()`;
  99. async function shClick(page, inner, name = null) {
  100. 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 }; })()`;
  101. const box = await page.waitFor(find, { label: 'selector ' + inner + ' ' + (name || '') });
  102. const at = { x: Math.round(box.x), y: Math.round(box.y), button: 'left', clickCount: 1 };
  103. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...at, buttons: 0 });
  104. await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...at, buttons: 1 });
  105. await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...at, buttons: 0 });
  106. }
  107. async function identSignIn(page, email) {
  108. await page.goto(IDENT_B + '/');
  109. await page.waitForSelector('#email');
  110. await hydrated(page);
  111. await page.type('#email', email);
  112. await page.click('#sendcode');
  113. // ident#20: a sent code NAVIGATES to ident's /code page; type only once that page is hydrated
  114. await page.waitFor('/\\/code$/.test(location.pathname) && !!document.querySelector("#code")', { label: 'ident /code page' });
  115. await hydrated(page);
  116. await page.type('#code', ident.lastCode(email));
  117. await page.click('#verify');
  118. await page.waitForSelector('#signout', { timeout: 10000 });
  119. }
  120. // the login button of the page we are on → ident's "choose an identity" → back
  121. async function buttonLogin(page) {
  122. await page.click('#loginbutton');
  123. await page.waitForSelector('#chooselist', { timeout: 10000 });
  124. await hydrated(page);
  125. await page.click('#chooselist li:nth-child(1) .choose');
  126. }
  127. async function nameIt(page, name) {
  128. await page.waitForSelector('#nameform');
  129. await hydrated(page);
  130. await page.type('#displayname', name);
  131. await page.click('#namesave');
  132. await page.waitFor('!document.querySelector("#nameform")', { label: 'name saved' });
  133. }
  134. async function createRepo(page, slug, description) {
  135. await page.evaluate('document.querySelector("#slug").value = ""; document.querySelector("#description").value = ""');
  136. await page.type('#slug', slug);
  137. if (description) await page.type('#description', description);
  138. await page.click('#createsave');
  139. }
  140. // the FIRST HTML of a page as the server sends it, for any Host (node's fetch cannot set Host) — gitoria#16
  141. const firstHtml = (path, host, cookie) => new Promise((res, rej) => {
  142. const rq = httpRequest({ host: '127.0.0.1', port: PORT, path, headers: { host, ...(cookie ? { cookie } : {}) } }, (r) => {
  143. let b = ''; r.setEncoding('utf8'); r.on('data', d => b += d); r.on('end', () => res({ status: r.statusCode, headers: r.headers, body: b }));
  144. });
  145. rq.on('error', rej); rq.end();
  146. });
  147. const bodyText = (html) => ((html.match(/<body>([\s\S]*)<\/body>/) || [])[1] || '').replace(/<script[\s\S]*?<\/script>/g, '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');
  148. const emitFace = async (event, payload, cookie) => {
  149. const r = await fetch(API + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: 1, event, payload }) });
  150. const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch {}
  151. return { status: r.status, raw: t, value: j && j.value };
  152. };
  153. try {
  154. // ---- our own ident; gitoria is registered with ONE origin: the main address ------------------------
  155. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });
  156. const alice = await ident.signIn('[email protected]');
  157. const gateAcct = await ident.signIn('[email protected]');
  158. const APPKEY = await ident.registerApp(alice, 'gitoria (gate)', [BASE]);
  159. 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')));
  160. // our own tickets (a copy without .env), with a token for its user "gitoria" — gitoria opens tickets with it
  161. tickets = await startTickets({ ticketsDir: TICKETS_DIR, workDir: join(STORE, 'tickets'), port: TICKETS_PORT, ident, who: alice, origins: ORIGINS });
  162. 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')));
  163. const env = { IDENT_API_KEY: APPKEY.key, IDENT_API_SECRET: APPKEY.secret, GITORIA_TICKETS_URL: tickets.base };
  164. startServer(env);
  165. await serverUp();
  166. // ---- the API (reads are public) --------------------------------------------------------------
  167. let r = await fetch(API + '/api/repos');
  168. check('api: GET /api/repos is empty at the start', r.status === 200 && J(await r.json()) === '{"repos":[]}');
  169. r = await fetch(API + '/api/repos', { method: 'POST', body: '{}' });
  170. check('api: POST /api/repos → 405 (creating is a web action)', r.status === 405);
  171. r = await fetch(API + '/api/repos/nothing');
  172. check('api: unknown repo → 404', r.status === 404);
  173. r = await fetch(API + '/api/repos', { headers: { accept: 'text/markdown' } });
  174. check('api: Markdown read view of the list', /text\/markdown/.test(r.headers.get('content-type')) && /^# Repositories \(0\)/.test(await r.text()));
  175. // ---- the login button's server half: where does ?next= lead? ------------------------------------
  176. const cbCookie = 'gitoriasid=' + 'ab'.repeat(16);
  177. // a failed login is a PAGE now (components/loginfailed.hl, gitoria#16): the reason is parked in the session → /login/failed
  178. const failedPage = async (url) => {
  179. const c = await fetch(url, { redirect: 'manual' });
  180. const ck = (c.headers.get('set-cookie') || '').split(';')[0];
  181. const pg = await firstHtml('/login/failed', `gitoria.test:${PORT}`, ck);
  182. return { status: c.status, location: c.headers.get('location'), cookie: ck, page: bodyText(pg.body), pageStatus: pg.status };
  183. };
  184. let lf = await failedPage(API + '/login/callback');
  185. 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));
  186. lf = await failedPage(API + '/login/callback?ident_code=' + 'ab'.repeat(24));
  187. check('login: an unknown code → /login/failed says "ident refused"', lf.status === 302 && lf.location === '/login/failed' && /ident refused/.test(lf.page), J(lf));
  188. const nexts = [
  189. ['/', '/'], ['/x?y=1', '/x?y=1'], [REPO('alpha') + '/code', REPO('alpha') + '/code'], [REPO('alpha') + '/', REPO('alpha') + '/'], [REPO('alpha'), REPO('alpha') + '/'],
  190. ['//evil.example', '/'], ['http://evil.example/', '/'], [REPO('www') + '/', '/'], [`http://alpha.gitoria.test:${PORT}.evil.example/`, '/'],
  191. [REPO('alpha') + '/a b', '/'], [REPO('a--b') + '/', '/'], [REPO('Alpha') + '/', '/'], ['/x\r\nSet-Cookie: a=b', '/'], ['/login/callback', '/'], ['', '/'],
  192. [`https://alpha.gitoria.test:${PORT}/`, '/'], [`http://x.y.gitoria.test:${PORT}/`, '/'],
  193. ];
  194. let allNext = true, nextDetail = '';
  195. for (const [want, expect] of nexts) {
  196. const code = await ident.selectorCode(gateAcct, APPKEY, BASE);
  197. const c = await fetch(API + '/login/callback?next=' + encodeURIComponent(want) + '&ident_code=' + code, { redirect: 'manual' });
  198. const loc = c.headers.get('location');
  199. if (c.status !== 302 || loc !== expect) { allNext = false; nextDetail += ` [${J(want)} → ${c.status} ${loc}, wanted ${expect}]`; }
  200. }
  201. check('login: ?next= accepts a path or a valid repo address, everything else → /', allNext, nextDetail);
  202. const cookieCode = await ident.selectorCode(gateAcct, APPKEY, BASE);
  203. const cc = await fetch(API + '/login/callback?ident_code=' + cookieCode, { redirect: 'manual' });
  204. const setCookie = cc.headers.get('set-cookie') || '';
  205. 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);
  206. // ---- the re-vendored Hybriel (mission 033): what the dropped local patches did is upstream now ------------
  207. r = await fetch(API + '/__hl/app.css');
  208. const css = await r.text();
  209. 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));
  210. // ---- forged face session: nobody logged in may create --------------------------------------------
  211. let f = await emitFace('gitoriaCreate', ['forged', '', { user: { id: 'x' } }]);
  212. check('face: a forged trailing session argument creates nothing', !(await (await fetch(API + '/api/repos/forged')).ok) && !(f.value && f.value.repo), f.raw);
  213. f = await emitFace('gitoriaCreate', ['anon', '']);
  214. check('face: not logged in → refused with a message', f.value && /log in/.test(f.value.error || ''), f.raw);
  215. // ---- A = alice in a browser -----------------------------------------------------------------------
  216. const A = await newPage();
  217. await identSignIn(A, '[email protected]');
  218. await A.goto(BASE + '/');
  219. await A.waitFor('!!document.querySelector("#heading")', { label: 'main address shows the list' });
  220. await hydrated(A);
  221. 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')));
  222. 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')}`);
  223. await buttonLogin(A);
  224. await A.waitFor(`location.href === ${J(BASE + '/')} && !!document.querySelector("#nameform")`, { label: 'A back with the name prompt' });
  225. check('button: back on the main address, logged in, asked for a display name', await has(A, '#whoami') && !(await has(A, '#loginbutton')));
  226. await nameIt(A, 'alice');
  227. await A.waitForSelector('#createform');
  228. check('named: the "create a repository" form appears', (await txt(A, '#whoami')) === 'alice');
  229. // ---- B = a signed-out viewer of the main address, for the live list ---------------------------------
  230. const B = await newPage();
  231. await B.goto(BASE + '/');
  232. await B.waitForSelector('#empty');
  233. await hydrated(B);
  234. // ---- create ---------------------------------------------------------------------------------------
  235. const errAfter = async (slug, want) => {
  236. await createRepo(A, slug, '');
  237. await A.waitFor(`new RegExp(${J(want)}).test(document.querySelector("#createerror").textContent)`, { label: 'error for ' + slug });
  238. const e = await txt(A, '#createerror');
  239. check(`create: "${slug}" is refused — ${want}`, new RegExp(want).test(e), e);
  240. return e;
  241. };
  242. await errAfter('Bad Slug', 'only have lowercase');
  243. await errAfter('www', 'reserved');
  244. await errAfter('ab-', 'starts and ends');
  245. await errAfter('ab--cd', 'two hyphens');
  246. await errAfter('-abc', 'starts and ends');
  247. check('create: nothing was stored by the refusals', J(await (await fetch(API + '/api/repos')).json()) === '{"repos":[]}');
  248. await A.evaluate('document.querySelector("#createerror").textContent = ""');
  249. await createRepo(A, 'alpha', 'The first repository');
  250. await A.waitForSelector('#created');
  251. 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') + '/');
  252. 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'));
  253. await B.waitFor('document.querySelectorAll("#repos li").length === 1', { label: 'B sees alpha live' });
  254. check('live: the signed-out viewer B sees alpha without a reload', /alpha/.test(await txt(B, '#repos')) && !(await has(B, '#empty')));
  255. await errAfter('alpha', 'taken');
  256. await A.evaluate('document.querySelector("#createerror").textContent = ""');
  257. await createRepo(A, 'beta-2', '');
  258. await A.waitFor('document.querySelectorAll("#repos li").length === 2', { label: 'A: two repos' });
  259. await B.waitFor('document.querySelectorAll("#repos li").length === 2', { label: 'B: two repos' });
  260. 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');
  261. r = await fetch(API + '/api/repos/alpha');
  262. const alphaRow = await r.json();
  263. 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));
  264. r = await fetch(API + '/api/repos/alpha', { headers: { accept: 'text/markdown' } });
  265. const md = await r.text();
  266. check('api: Markdown read view of a repo', /^# alpha\n/.test(md), md);
  267. // ---- the address: <slug>.gitoria.test ------------------------------------------------------------------
  268. await A.goto(REPO('alpha') + '/');
  269. await A.waitFor('!!document.querySelector("#reponame")', { label: 'alpha at its own address' });
  270. 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'));
  271. check('alpha.<domain>: the login is shared with the main address (same cookie)', (await txt(A, '#whoami')) === 'alice');
  272. check('alpha.<domain>: the title is the repo page, tab title set', (await A.evaluate('document.title')) !== '');
  273. await A.goto(REPO('nothing-here') + '/');
  274. await A.waitFor('!!document.querySelector("#missing")', { label: 'unknown address' });
  275. 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 + '/');
  276. await A.goto(REPO('www') + '/');
  277. await A.waitFor('!!document.querySelector("#missing")', { label: 'reserved address' });
  278. check('a reserved address is no repo either', true);
  279. await A.goto(BASE + '/');
  280. await A.waitFor('!!document.querySelector("#repos li")', { label: 'back on main' });
  281. await A.click('#repos li:nth-child(2) .slug');
  282. await A.waitFor(`location.hostname === 'alpha.gitoria.test' && !!document.querySelector("#reponame")`, { label: 'click on alpha' });
  283. check('clicking a repo in the list opens its own address', (await txt(A, '#reponame')) === 'alpha');
  284. // ---- login FROM a repo address: through the main address and back --------------------------------------
  285. await identSignIn(B, '[email protected]');
  286. await B.goto(REPO('alpha') + '/');
  287. await B.waitFor('!!document.querySelector("#reponame")', { label: 'B at alpha' });
  288. check('signed out at alpha.<domain>: repo visible, login button there', (await has(B, '#loginbutton')) && !(await has(B, '#whoami')));
  289. await hydrated(B);
  290. await buttonLogin(B);
  291. await B.waitFor(`location.href === ${J(REPO('alpha') + '/')} && !!document.querySelector("#whoami")`, { label: 'B logged in back at alpha' });
  292. await B.waitFor('!!document.querySelector("#reponame")', { label: 'B: the repo page after the login' });
  293. 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)'));
  294. await B.goto(BASE + '/');
  295. await B.waitForSelector('#createform');
  296. check('the same login holds on the main address (cookie for all addresses)', (await txt(B, '#whoami')) === 'alice');
  297. // ---- the homepage of a repo (ticket #8): README.md, or the $docs folder --------------------------------------
  298. check('a new repo has an empty bare git repository', existsSync(join(STORE, 'git', 'alpha.git', 'HEAD')), J(existsSync(join(STORE, 'git'))));
  299. const pushFiles = (slug, files) => { // a commit into the bare repo, the way a push would leave it
  300. const work = join(STORE, 'work-' + slug);
  301. rmSync(work, { recursive: true, force: true }); mkdirSync(work, { recursive: true });
  302. const git = (...a) => execFileSync('git', ['-C', work, '-c', 'user.name=t', '-c', '[email protected]', ...a], { stdio: 'pipe' });
  303. git('init', '-q', '-b', 'main');
  304. for (const [p, c] of Object.entries(files)) { mkdirSync(dirname(join(work, p)), { recursive: true }); writeFileSync(join(work, p), c); }
  305. git('add', '-A'); git('commit', '-q', '-m', 'files');
  306. git('push', '-q', '-f', join(STORE, 'git', slug + '.git'), 'main');
  307. };
  308. 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'); };
  309. await A.goto(REPO('alpha') + '/');
  310. check('an empty repo says it has no README.md yet', /no README\.md yet/.test(await homeText(A)));
  311. pushFiles('alpha', {
  312. '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',
  313. 'docs/other.md': '# not the homepage\n',
  314. });
  315. await A.goto(REPO('alpha') + '/');
  316. const alphaText = await homeText(A);
  317. 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));
  318. 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'))); })()`));
  319. pushFiles('alpha', {
  320. 'README.md': '# Root readme\n',
  321. '$docs/b-second.md': '# Second doc\n\nText two.\n',
  322. '$docs/a-first.md': '# First doc\n\nText one.\n',
  323. '$docs/sub/c-third.md': '## Third doc\n',
  324. '$docs/notes.txt': 'not markdown\n',
  325. });
  326. await A.goto(REPO('alpha') + '/');
  327. await homeText(A);
  328. 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)'));
  329. 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)); })());
  330. for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {
  331. await viewport(A, w, h);
  332. 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' });
  333. await A.goto(REPO('beta-2') + '/');
  334. await homeText(A);
  335. check(`layout ${w}px: the homepage has no horizontal overflow`, await noOverflow(A));
  336. await shot(A, `${name}-home`);
  337. }
  338. // ---- browsing code (ticket #9): /code, /branch/<name>, /commit/<id> ------------------------------------------
  339. const gitIn = (work, ...a) => execFileSync('git', ['-C', work, '-c', 'user.name=t', '-c', '[email protected]', ...a], { stdio: 'pipe' }).toString();
  340. const work = join(STORE, 'work-code');
  341. rmSync(work, { recursive: true, force: true }); mkdirSync(join(work, 'src', 'deep'), { recursive: true });
  342. gitIn(work, 'init', '-q', '-b', 'main');
  343. writeFileSync(join(work, 'README.md'), '# Code repo\n');
  344. writeFileSync(join(work, 'src', 'a.txt'), 'line one\n\n indented <b>&amp;</b>\n');
  345. writeFileSync(join(work, 'src', 'deep', 'b.txt'), 'deep file\n');
  346. writeFileSync(join(work, 'my file.txt'), 'spaced name\n');
  347. writeFileSync(join(work, 'bin.dat'), Buffer.from([0, 1, 2, 255, 0, 254, 0, 0]));
  348. gitIn(work, 'add', '-A'); gitIn(work, 'commit', '-q', '-m', 'first commit');
  349. const firstSha = gitIn(work, 'rev-parse', 'HEAD').trim();
  350. writeFileSync(join(work, 'src', 'a.txt'), 'main changed\n');
  351. gitIn(work, 'commit', '-qam', 'second on main');
  352. const mainSha = gitIn(work, 'rev-parse', 'HEAD').trim();
  353. gitIn(work, 'checkout', '-q', '-b', 'feature/x', firstSha);
  354. writeFileSync(join(work, 'src', 'a.txt'), 'feature changed\n');
  355. writeFileSync(join(work, 'only-feature.txt'), 'f\n');
  356. gitIn(work, 'add', '-A'); gitIn(work, 'commit', '-q', '-m', 'feature work');
  357. gitIn(work, 'checkout', '-q', '-b', 'dev', firstSha);
  358. gitIn(work, 'push', '-q', '-f', join(STORE, 'git', 'alpha.git'), 'main', 'feature/x', 'dev');
  359. const codeReady = (p) => p.waitFor('!document.querySelector("#codeloading") && (!!document.querySelector("#codemessage") || !!document.querySelector("#codefile") || !!document.querySelector("#entries") || !!document.querySelector("#noentries"))', { label: 'code answer' });
  360. const names = (p, sel) => p.evaluate(`[...document.querySelectorAll(${J(sel)})].map(x => x.textContent.trim()).join('|')`);
  361. await viewport(A, 1280, 900);
  362. await A.goto(REPO('alpha') + '/code');
  363. await codeReady(A);
  364. 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)'));
  365. 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'));
  366. await A.click('#entries a.dir');
  367. await A.waitFor('location.pathname === "/code/src" && !!document.querySelector("#crumbs strong") && document.querySelector("#crumbs strong").textContent === "src" && !!document.querySelector("#entries")', { label: 'folder opened' });
  368. 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)'));
  369. await A.goto(REPO('alpha') + '/code/src/a.txt');
  370. await codeReady(A);
  371. 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)'));
  372. await A.goto(REPO('alpha') + '/commit/' + firstSha + '/src/a.txt');
  373. await codeReady(A);
  374. 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>&amp;</b>' && (await txt(A, '#codekind')) === 'Commit' && !(await has(A, '#lines script')), await A.evaluate('document.body.innerText.slice(0, 300)'));
  375. await A.goto(REPO('alpha') + '/commit/' + firstSha.slice(0, 7));
  376. await codeReady(A);
  377. 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)'));
  378. await A.goto(REPO('alpha') + '/branch/feature/x');
  379. await codeReady(A);
  380. 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)'));
  381. await A.goto(REPO('alpha') + '/branch/feature/x/src/a.txt');
  382. await codeReady(A);
  383. 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)'));
  384. await A.goto(REPO('alpha') + '/code/my%20file.txt');
  385. await codeReady(A);
  386. check('a file with a space in its name opens', (await names(A, '#lines li')) === 'spaced name');
  387. await A.goto(REPO('alpha') + '/code/bin.dat');
  388. await codeReady(A);
  389. check('a binary file is not shown as text', (await has(A, '#binary')) && !(await has(A, '#lines li')));
  390. 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/]]) {
  391. await A.goto(REPO('alpha') + path);
  392. await codeReady(A);
  393. check(`${path}: a plain message, no crash`, want.test(await txt(A, '#codemessage') || ''), await A.evaluate('document.body.innerText.slice(0, 200)'));
  394. }
  395. await A.goto(BASE + '/');
  396. await A.waitForSelector('#createform');
  397. await hydrated(A);
  398. await createRepo(A, 'gamma', XSS);
  399. await A.waitFor('!!document.querySelector("#created")', { label: 'gamma created' });
  400. await A.goto(REPO('gamma') + '/code');
  401. await codeReady(A);
  402. 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)'));
  403. // "Add code to this repository" (gitoria#20): stands as plain text, not a collapsible, ONLY on the Code page of an
  404. // empty repo (gamma) — checked on every tab of an empty repo AND a filled one (alpha, has commits)
  405. 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"'));
  406. const addCodeTabs = [['/', '#nohomepage, #homepage'], ['/pulls', '#pullsmessage, #pullshelp'], ['/releases', '#releasesmessage, #releaseshelp'], ['/tickets', '#ticketsnotconnected, #ticketlist'], ['/settings', '#connecttickets, #notconnected']];
  407. for (const [p, sel] of addCodeTabs) {
  408. await A.goto(REPO('gamma') + p);
  409. await A.waitFor(`!!document.querySelector(${J(sel)})`, { label: 'gamma ' + p + ' for addcode check' });
  410. 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)'));
  411. }
  412. for (const [p, sel] of [['/code', '#entries, #codemessage'], ...addCodeTabs]) {
  413. await A.goto(REPO('alpha') + p);
  414. await A.waitFor(`!!document.querySelector(${J(sel)})`, { label: 'alpha ' + p + ' for addcode check' });
  415. check(`filled repo ${p}: no "Add code" box (already has commits)`, !(await has(A, '#addcode')), await A.evaluate('document.body.innerText.slice(0, 150)'));
  416. }
  417. await A.goto(REPO('alpha') + '/code');
  418. await codeReady(A);
  419. 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'));
  420. await hydrated(A); // the view is in the FIRST HTML now: wait for the page to be live before clicking
  421. await A.click('#branches .setmain');
  422. await A.waitFor('document.querySelector("#coderef") && document.querySelector("#coderef").textContent === "dev"', { label: 'main branch changed' });
  423. 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)'));
  424. await A.goto(REPO('alpha') + '/');
  425. await homeText(A);
  426. check('the homepage README follows the main branch too', await A.evaluate('!!document.querySelector("#homepage")'));
  427. const S = await newPage();
  428. await S.goto(REPO('alpha') + '/code');
  429. await codeReady(S);
  430. 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);
  431. const forged = await emitFace('gitoriaSetBranch', ['alpha', 'main', 'code', '', 'x'], '');
  432. const still = await (await fetch(API + '/api/repos/alpha')).json();
  433. check('face: a stranger cannot change the main branch', still.branch === 'dev', J(forged) + J(still));
  434. for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {
  435. await viewport(A, w, h);
  436. await A.goto(REPO('alpha') + '/code');
  437. await codeReady(A);
  438. check(`layout ${w}px: /code has no horizontal overflow`, await noOverflow(A));
  439. await shot(A, `${name}-code`);
  440. await A.goto(REPO('alpha') + '/code/src/a.txt');
  441. await codeReady(A);
  442. check(`layout ${w}px: a file view has no horizontal overflow of the page`, await noOverflow(A));
  443. await shot(A, `${name}-file`);
  444. }
  445. await viewport(A, 1280, 900);
  446. // the connect flow in the browser: the owner (alice, logged in at tickets with the gate's cookie) clicks "Tickets: connect"
  447. // in the repo's settings, picks the project on tickets and continues back to the repo
  448. const connectRepo = async (page, slug, projectValue, newTitle) => {
  449. const [ck, cv] = tickets.cookie.split('=');
  450. await page.send('Network.setCookie', { name: ck, value: cv, url: tickets.base });
  451. await page.goto(REPO(slug) + '/settings');
  452. await page.waitFor('!!document.querySelector("#connecttickets")', { label: 'settings: connect button' });
  453. await page.click('#connecttickets');
  454. await page.waitFor('location.origin === ' + J(tickets.base) + ' && !!document.querySelector("#connectform")', { label: 'tickets connect page' });
  455. await hydrated(page);
  456. if (newTitle) {
  457. await page.evaluate('(() => { const s = document.querySelector("#connectproject"); s.value = ""; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
  458. await page.waitFor('!!document.querySelector("#connecttitleinput")', { label: 'new project title field' });
  459. await page.type('#connecttitleinput', newTitle);
  460. } else if (projectValue) {
  461. await page.evaluate(`(() => { const s = document.querySelector("#connectproject"); s.value = ${J(projectValue)}; s.dispatchEvent(new Event("change", { bubbles: true })); })()`);
  462. }
  463. await page.click('#connectconfirm');
  464. try { await page.waitFor('!!document.querySelector("#connectcontinue")', { label: 'tickets: connected' }); }
  465. catch (e) { throw new Error(e.message + ' | page: ' + await page.evaluate('(document.querySelector("#connectmessage") || {}).textContent + " / " + document.body.innerHTML.slice(0, 1500)')); }
  466. await page.click('#connectcontinue');
  467. await page.waitFor(`location.hostname === '${slug}.gitoria.test' && !!document.querySelector("#connectedto")`, { label: 'back in settings, connected' });
  468. };
  469. // ---- the repo's tickets (gitoria#10): /tickets lists tickets.worldapi.org's project, the first ticket makes it ----
  470. const PROJ = 'alpha.gitoria.test';
  471. await A.goto(REPO('alpha') + '/tickets');
  472. await A.waitFor('!!document.querySelector("#ticketsnotconnected")', { label: 'tickets page, not connected' });
  473. check('connect: an unconnected repo says so on /tickets, no form, no list', !(await has(A, '#newticketform')) && !(await has(A, '#ticketlist')));
  474. await S.goto(REPO('alpha') + '/settings');
  475. await S.waitFor('!!document.querySelector("#notconnected")', { label: 'S: settings' });
  476. check('connect: a signed-out viewer sees "not connected" and no connect button', !(await has(S, '#connecttickets')) && !(await has(S, '#disconnecttickets')));
  477. // the owner connects: the project exists in tickets (made by the tickets user of alice, its admin)
  478. 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 }) });
  479. check('connect: the project for alpha exists in tickets', mk.status === 201 || mk.status === 200, String(mk.status));
  480. await connectRepo(A, 'alpha', null, null);
  481. check('connect: settings says connected to that project, with the disconnect button', /alpha/.test(await txt(A, '#connectedto')) && (await has(A, '#disconnecttickets')));
  482. 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`))));
  483. await A.goto(REPO('alpha') + '/tickets');
  484. await A.waitFor('!!document.querySelector("#noticketsyet")', { label: 'tickets page, connected, no ticket yet' });
  485. 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')));
  486. await S.goto(REPO('alpha') + '/tickets');
  487. await S.waitFor('!!document.querySelector("#noticketsyet")', { label: 'S: tickets page' });
  488. await hydrated(S);
  489. check('tickets: a signed-out viewer sees the page with a login hint and no form', (await has(S, '#ticketsloginhint')) && !(await has(S, '#newticketform')));
  490. await hydrated(A);
  491. await A.evaluate('document.querySelector("#newticket").open = true');
  492. await A.type('#ticketsubject', ' ');
  493. await A.click('#ticketsave');
  494. await A.waitFor('!!document.querySelector("#newticketerror").textContent', { label: 'blank subject refused' });
  495. await A.evaluate('document.querySelector("#ticketsubject").value = ""; document.querySelector("#newticket").open = true');
  496. check('tickets: a blank subject is refused in words', /subject/.test(await txt(A, '#newticketerror')));
  497. await A.type('#ticketsubject', 'First <b>ticket</b>');
  498. await A.type('#ticketsummary', 'Some **words**');
  499. await A.click('#ticketsave');
  500. await A.waitFor('document.querySelectorAll("#ticketlist li").length === 1', { label: 'first ticket listed' });
  501. const t1 = (await tickets.api(`/api/projects/${PROJ}/tickets/1`)).json.ticket;
  502. 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));
  503. 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')}; })()`));
  504. // the server pushes `ticketOpened` BEFORE it answers the face, so the list row can show up before openIt clears the form
  505. // (seen once on hybriel master ff51cf46) — wait for the face's answer, the notice
  506. await A.waitFor('!!document.querySelector("#ticketnotice")', { label: 'notice after the face answered' }).catch(() => null);
  507. 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') }));
  508. await S.waitFor('document.querySelectorAll("#ticketlist li").length === 1', { label: 'S sees it live' });
  509. check('live: the signed-out viewer sees the new ticket without a reload', /First/.test(await txt(S, '#ticketlist')) && !(await has(S, '#noticketsyet')));
  510. // a ticket opened in tickets itself, and one with a name only tickets knows: shown on the next load
  511. const 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' }) });
  512. check('tickets: a ticket made on tickets.worldapi.org directly → 201', ext.status === 201);
  513. await A.goto(REPO('alpha') + '/tickets');
  514. await A.waitFor('document.querySelectorAll("#ticketlist li").length === 2', { label: 'two tickets after reload' });
  515. 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);
  516. check('tickets: another repo has none of them', (await tickets.api('/api/projects/beta-2.gitoria.test/tickets')).status === 404);
  517. await S.goto(REPO('beta-2') + '/tickets');
  518. await S.waitFor('!!document.querySelector("#ticketsnotconnected")', { label: 'beta-2 is not connected' });
  519. check('tickets: beta-2 is not connected and shows none of them', !(await has(S, '#ticketlist')));
  520. await S.goto(REPO('nothing-here') + '/tickets');
  521. await S.waitFor('!!document.querySelector("#missing")', { label: 'unknown repo tickets' });
  522. check('tickets: an address nobody created has no tickets page', true);
  523. f = await emitFace('gitoriaOpenTicket', ['alpha', 'anon', '']);
  524. 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);
  525. f = await emitFace('gitoriaOpenTicket', ['alpha', 'forged', '', { user: { id: 'x' } }]);
  526. 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);
  527. f = await emitFace('gitoriaOpenTicket', ['no-such-repo', 'x', '']);
  528. 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);
  529. for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {
  530. await viewport(A, w, h);
  531. await A.goto(REPO('alpha') + '/tickets');
  532. await A.waitFor('document.querySelectorAll("#ticketlist li").length === 2', { label: 'tickets for layout' });
  533. check(`layout ${w}px: /tickets has no horizontal overflow`, await noOverflow(A));
  534. await shot(A, `${name}-tickets`);
  535. }
  536. await viewport(A, 1280, 900);
  537. await tickets.stop();
  538. await S.goto(REPO('alpha') + '/tickets');
  539. await S.waitFor('!!document.querySelector("#ticketserror")', { label: 'tickets down' });
  540. check('tickets down: the page says tickets.worldapi.org did not answer', /did not answer/.test(await txt(S, '#ticketserror')));
  541. tickets = await startTickets({ ticketsDir: TICKETS_DIR, workDir: join(STORE, 'tickets2'), port: TICKETS_PORT, ident, who: alice, origins: ORIGINS });
  542. // ---- #N links both ways (gitoria#18): the tickets copy is new; connect alpha again, then a REAL push over HTTP ----
  543. await fetch(tickets.base + '/api/projects', { method: 'POST', headers: { authorization: 'Bearer ' + tickets.token, 'content-type': 'application/json' }, body: J({ title: PROJ, slug: PROJ }) });
  544. 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' }) });
  545. await connectRepo(A, 'alpha', null, null);
  546. check('connect: connecting again after tickets was replaced works (new key, page says connected)', /alpha/.test(await txt(A, '#connectedto')));
  547. const sid = (await A.cookies([REPO('alpha')])).find(c => /sid$/.test(c.name));
  548. const mkTok = await emitFace('gitoriaMakeToken', ['gate push'], sid.name + '=' + sid.value);
  549. const PUSHTOKEN = mkTok.value && mkTok.value.token;
  550. check('links: a push token for alice', /^gtr_[0-9a-f]{40}$/.test(PUSHTOKEN || ''), mkTok.raw);
  551. const lw = join(STORE, 'work-links');
  552. rmSync(lw, { recursive: true, force: true });
  553. execFileSync('git', ['clone', '-q', join(STORE, 'git', 'alpha.git'), lw], { stdio: 'pipe' });
  554. const lg = (...a) => execFileSync('git', ['-C', lw, '-c', 'user.name=alice', '-c', '[email protected]', ...a], { stdio: 'pipe' }).toString();
  555. 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);
  556. const ticketMd = async (n) => (await (await fetch(`${tickets.base}/api/projects/${PROJ}/tickets/${n}`, { headers: { accept: 'text/markdown' } })).text());
  557. writeFileSync(join(lw, 'login.txt'), 'x\n'); lg('add', '-A'); lg('commit', '-q', '-m', 'fix login, #1 and a bogus #99');
  558. const mentionSha = lg('rev-parse', 'HEAD').trim();
  559. pushHttp('HEAD:main');
  560. let tmd = '';
  561. 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); }
  562. 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));
  563. check('links: a ticket that does not exist (#99) is skipped quietly', (await tickets.api(`/api/projects/${PROJ}/tickets/99`)).status === 404);
  564. const cnt = (t) => (t.match(/Mentioned in commit/g) || []).length;
  565. pushHttp('HEAD:refs/heads/other');
  566. await sleep(1500);
  567. check('links: pushing the same commit again does not comment twice', cnt(await ticketMd(1)) === 1, String(cnt(await ticketMd(1))));
  568. await A.goto(REPO('alpha') + '/commit/' + mentionSha);
  569. await A.waitFor('!!document.querySelector("#reponame")', { label: 'commit page' });
  570. await sleep(1500);
  571. 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`));
  572. // "fixes #1" in a merged pull request sets the ticket to review
  573. lg('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');
  574. pushHttp('fixbr');
  575. await A.goto(REPO('alpha') + '/pulls');
  576. await A.waitFor('!!document.querySelector("#reponame")', { label: 'pulls page' });
  577. await sleep(1500);
  578. 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')})`));
  579. check('links: an open pull request does not change the ticket', (await tickets.api(`/api/projects/${PROJ}/tickets/1`)).json.ticket.state === 'open');
  580. lg('checkout', '-q', 'main'); lg('merge', '-q', '--no-ff', '-m', 'merge the fix', 'fixbr');
  581. pushHttp('main');
  582. let st = '';
  583. 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); }
  584. check('links: the pull request "fixes #1" is merged (pushed) → ticket 1 is set to review', st === 'review', st);
  585. const before2 = cnt(await ticketMd(1));
  586. pushHttp('main');
  587. await sleep(1000);
  588. 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);
  589. // disconnect from gitoria's side, and tickets' side sees it gone
  590. await A.goto(REPO('alpha') + '/settings');
  591. await A.waitFor('!!document.querySelector("#disconnecttickets")', { label: 'settings, connected' });
  592. await hydrated(A);
  593. await A.click('#disconnecttickets');
  594. await A.waitFor('!!document.querySelector("#notconnected")', { label: 'disconnected' });
  595. await A.goto(REPO('alpha') + '/tickets');
  596. await A.waitFor('!!document.querySelector("#ticketsnotconnected")', { label: 'tickets page after disconnect' });
  597. check('connect: "Forget the connection" → /tickets says not connected again', true);
  598. await connectRepo(A, 'alpha', null, null);
  599. // ---- pull requests (gitoria#11): /pulls lists the commits that start with |||PR -------------------------------
  600. const pw = join(STORE, 'work-pulls');
  601. rmSync(pw, { recursive: true, force: true }); mkdirSync(pw, { recursive: true });
  602. gitIn(pw, 'init', '-q', '-b', 'main');
  603. writeFileSync(join(pw, 'a.txt'), 'base\n'); gitIn(pw, 'add', '-A'); gitIn(pw, 'commit', '-q', '-m', 'base');
  604. const baseSha = gitIn(pw, 'rev-parse', 'HEAD').trim();
  605. 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(); };
  606. gitIn(pw, 'branch', 'dev');
  607. const fixSha = branchCommit('fix-a', '|||PR Fix the <b>thing</b>');
  608. gitIn(pw, 'commit', '-q', '--allow-empty', '-m', 'more work on fix-a');
  609. branchCommit('topic/one', '|||PR|dev] Topic to dev');
  610. branchCommit('ghost', '|||PR|nope] Lost target');
  611. branchCommit('plain', 'not a pull request |||PR');
  612. branchCommit('nospace', '|||PR|main]glued');
  613. branchCommit('old', '|||PR Old work');
  614. gitIn(pw, 'checkout', '-q', 'main'); gitIn(pw, 'merge', '-q', '--ff-only', 'old');
  615. gitIn(pw, 'push', '-q', '-f', join(STORE, 'git', 'beta-2.git'), 'main', 'dev', 'fix-a', 'topic/one', 'ghost', 'plain', 'nospace', 'old');
  616. const pullsReady = (p) => p.waitFor('!document.querySelector("#pullsloading") && (!!document.querySelector("#pullsmessage") || !!document.querySelector("#pullshelp"))', { label: 'pulls answer' });
  617. await A.goto(REPO('beta-2') + '/pulls');
  618. await pullsReady(A);
  619. 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(' ~ '))`);
  620. 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));
  621. 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));
  622. 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')`));
  623. await A.evaluate(`[...document.querySelectorAll('#pulllist a.source')].find(a => a.textContent === 'fix-a').click()`);
  624. await A.waitFor('location.pathname === "/branch/fix-a"', { label: 'source branch link' });
  625. await S.goto(REPO('beta-2') + '/pulls');
  626. await pullsReady(S);
  627. check('/pulls: a signed-out viewer sees the same list', (await S.evaluate('document.querySelectorAll("#pulllist li").length')) === 4);
  628. await S.goto(REPO('gamma') + '/pulls');
  629. await pullsReady(S);
  630. check('/pulls of an empty repo says it has no commits yet', /no commits yet/.test(await txt(S, '#pullsmessage') || ''));
  631. await S.goto(REPO('alpha') + '/pulls');
  632. await pullsReady(S);
  633. 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")')));
  634. await S.goto(REPO('nothing-here') + '/pulls');
  635. await S.waitFor('!!document.querySelector("#missing")', { label: 'unknown repo pulls' });
  636. const forgedPulls = await emitFace('gitoriaPulls', ['beta-2', 'x', 'evil'], '');
  637. check('face: a forged session gets no pull list', !forgedPulls.value || forgedPulls.value.ok !== true, J(forgedPulls));
  638. for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {
  639. await viewport(A, w, h);
  640. await A.goto(REPO('beta-2') + '/pulls');
  641. await pullsReady(A);
  642. check(`layout ${w}px: /pulls has no horizontal overflow`, await noOverflow(A));
  643. await shot(A, `${name}-pulls`);
  644. }
  645. await viewport(A, 1280, 900);
  646. // ---- merge button (gitoria#17): the owner merges a pull request, only by clicking; a conflict merges nothing ------
  647. const mergeBtns = (p) => p.evaluate(`[...document.querySelectorAll('#pulllist li')].filter(li => li.querySelector('button.mergepr')).map(li => li.querySelector('.subject').textContent).join('|')`);
  648. await A.goto(REPO('beta-2') + '/pulls');
  649. await pullsReady(A);
  650. 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));
  651. await S.goto(REPO('beta-2') + '/pulls');
  652. await pullsReady(S);
  653. check('merge: a signed-out viewer sees no Merge button', (await S.evaluate('document.querySelectorAll("button.mergepr").length')) === 0);
  654. const forgedMerge = await emitFace('gitoriaMergePull', ['beta-2', fixSha, 'evil'], '');
  655. check('merge: a forged session cannot merge', !forgedMerge.value || forgedMerge.value.error != null || forgedMerge.value.result == null, J(forgedMerge));
  656. // a branch that conflicts with main
  657. gitIn(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');
  658. 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');
  659. gitIn(pw, 'push', '-q', '-f', join(STORE, 'git', 'beta-2.git'), 'main', 'clash');
  660. const mainBefore = gitIn(join(STORE, 'git', 'beta-2.git'), 'rev-parse', 'main').trim();
  661. await A.goto(REPO('beta-2') + '/pulls');
  662. await pullsReady(A);
  663. const clickMerge = (title) => A.evaluate(`(() => { const li = [...document.querySelectorAll('#pulllist li')].find(l => l.querySelector('.subject').textContent === ${J(title)}); li.querySelector('button.mergepr').click(); })()`);
  664. await clickMerge('Clashing change');
  665. 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')); });
  666. 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);
  667. await clickMerge('Fix the <b>thing</b>');
  668. 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')); });
  669. const bare = join(STORE, 'git', 'beta-2.git');
  670. const mainLog = gitIn(bare, 'log', '--format=%s|%an', 'main');
  671. 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);
  672. 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);
  673. 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()`)]));
  674. await S.goto(REPO('beta-2') + '/pulls');
  675. await pullsReady(S);
  676. 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'))`));
  677. await viewport(A, 1280, 900);
  678. // ---- releases (gitoria#12): /releases lists the |||RL commits of the main branch, versions counted up ---------
  679. const rw = join(STORE, 'work-rel');
  680. rmSync(rw, { recursive: true, force: true }); mkdirSync(rw, { recursive: true });
  681. gitIn(rw, 'init', '-q', '-b', 'main');
  682. const rc = (msg) => { gitIn(rw, 'commit', '-q', '--allow-empty', '-m', msg); return gitIn(rw, 'rev-parse', 'HEAD').trim(); };
  683. rc('start');
  684. rc('|||RL First <b>one</b>');
  685. rc('work');
  686. rc('|||RL|med Feature drop');
  687. rc('|||RL|mj Big one');
  688. rc('|||RL');
  689. rc('text |||RL not a release');
  690. rc('|||RL|nonsense x');
  691. rc('|||RL|1.1.1a By hand');
  692. const lastSha = rc('|||RL after the hand one');
  693. rc('|||RL|1.1.1a again the same');
  694. gitIn(rw, 'checkout', '-q', '-b', 'side'); rc('|||RL on a side branch');
  695. gitIn(rw, 'push', '-q', '-f', join(STORE, 'git', 'beta-2.git'), 'main', 'side');
  696. const relReady = (p) => p.waitFor('!document.querySelector("#releasesloading") && (!!document.querySelector("#releasesmessage") || !!document.querySelector("#releaseshelp"))', { label: 'releases answer' });
  697. await A.goto(REPO('beta-2') + '/releases');
  698. await relReady(A);
  699. 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)`);
  700. 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));
  701. 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));
  702. 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')`));
  703. await S.goto(REPO('gamma') + '/releases');
  704. await relReady(S);
  705. check('/releases of an empty repo says it has no commits yet', /no commits yet/.test(await txt(S, '#releasesmessage') || ''));
  706. await S.goto(REPO('alpha') + '/releases');
  707. await relReady(S);
  708. check('/releases of a repo without release commits says "No release yet"', (await has(S, '#noreleases')) && !(await has(S, '#releaselist li')));
  709. const forgedRel = await emitFace('gitoriaReleases', ['beta-2', 'x', 'evil'], '');
  710. check('face: a forged session gets no release list', !forgedRel.value || forgedRel.value.ok !== true, J(forgedRel));
  711. for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {
  712. await viewport(A, w, h);
  713. await A.goto(REPO('beta-2') + '/releases');
  714. await relReady(A);
  715. check(`layout ${w}px: /releases has no horizontal overflow`, await noOverflow(A));
  716. await shot(A, `${name}-releases`);
  717. }
  718. await viewport(A, 1280, 900);
  719. // ---- gitoria#16: every repo view is its own page, rendered on the SERVER for the request's host ---------------
  720. const AH = `alpha.gitoria.test:${PORT}`;
  721. // the git views are read on the server too (hl:proc run(), hybriel#80): the FIRST HTML holds the content, no loading step
  722. const BH = `beta-2.gitoria.test:${PORT}`;
  723. const firstViews = [
  724. ['/', AH, (b, t) => /id="homepage"/.test(b) && /Code repo/.test(t), 'Readme: the README (main branch dev)'],
  725. ['/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'],
  726. ['/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)'],
  727. ['/code/src/a.txt', AH, (b, t) => /id="lines"/.test(b) && /line one/.test(t), 'Code/<file>: the file lines'],
  728. ['/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'],
  729. ['/branch/feature/x', AH, (b, t) => /id="coderef">feature\/x</.test(b) && /only-feature\.txt/.test(t), 'Branch with a slash'],
  730. ['/commit/' + firstSha, AH, (b, t) => /id="codekind"[^>]*>Commit</.test(b) && /id="entries"/.test(b) && /first commit/.test(t), 'Commit: the project at that commit'],
  731. ['/pulls', BH, (b, t) => /id="pulllist"/.test(b) && /Fix the/.test(t), 'Pull requests: the list'],
  732. ['/releases', BH, (b, t) => /id="releaselist"/.test(b) && /1\.1\.2/.test(t) && /after the hand one/.test(t), 'Releases: the list'],
  733. ];
  734. for (const [path, host, ok, what] of firstViews) {
  735. const f = await firstHtml(path, host);
  736. const t = bodyText(f.body);
  737. 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));
  738. }
  739. r = await fetch(API + '/host.js');
  740. check('/host.js is gone (404)', r.status === 404, String(r.status));
  741. let fh, ft;
  742. // (the tickets copy was restarted empty above) one ticket made in tickets itself, then the page from scratch
  743. // tickets #20: a ticket needs an existing project, made by its first admin (as gitoria does)
  744. 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' }) });
  745. 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' }) });
  746. fh = await firstHtml('/tickets', AH);
  747. ft = bodyText(fh.body);
  748. 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));
  749. fh = await firstHtml('/', `gitoria.test:${PORT}`);
  750. ft = bodyText(fh.body);
  751. 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));
  752. 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]);
  753. let allMissing = true, missDetail = '';
  754. for (const p of ['/', '/code', '/code/src', '/branch/main', '/commit/abcdef1', '/pulls', '/releases', '/tickets']) {
  755. const m = await firstHtml(p, `nothing-here.gitoria.test:${PORT}`);
  756. 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; }
  757. }
  758. check('unknown repo: every view says "No such repository" in the first HTML', allMissing, missDetail);
  759. // direct load of every view in Chrome
  760. const views = [
  761. ['/', '#homepage, #nohomepage', 'Readme'], ['/code', '#entries', 'Code'], ['/code/src/a.txt', '#lines', 'Code'], ['/branch/feature/x', '#entries', 'Branch'],
  762. ['/commit/' + firstSha.slice(0, 8), '#entries', 'Commit'], ['/pulls', '#pullshelp', 'Pull requests'], ['/releases', '#releaseshelp', 'Releases'], ['/tickets', '#ticketlist', 'Tickets'],
  763. ];
  764. let direct = true, directDetail = '';
  765. for (const [p, sel, title] of views) {
  766. await A.goto(REPO('alpha') + p);
  767. try { await A.waitFor(`!!document.querySelector(${J(sel)}) && !!document.querySelector('#reponame')`, { label: 'direct ' + p, timeout: 6000 }); } catch { direct = false; directDetail += ' ' + p + ' (no ' + sel + ')'; continue; }
  768. const t = await A.evaluate('document.title');
  769. if (!t.startsWith(title === 'Readme' ? 'alpha' : title) || !t.includes('alpha')) { direct = false; directDetail += ` ${p} title ${J(t)}`; }
  770. }
  771. check('direct load of every view (Readme, Code, a file, Branch, Commit, Pulls, Releases, Tickets): its content, its tab title', direct, directDetail);
  772. // hl:web navigation: Readme → Code → Releases → Tickets → Pulls → Readme without a page load
  773. await A.goto(REPO('alpha') + '/');
  774. await A.waitFor('!!document.querySelector("#homepage, #nohomepage")', { label: 'nav start: Readme' });
  775. await hydrated(A);
  776. await A.evaluate('window.__navMarker = 42');
  777. const navSteps = [['#navcode', '/code', '#entries'], ['#navreleases', '/releases', '#releaseshelp'], ['#navtickets', '/tickets', '#ticketlist'], ['#navpulls', '/pulls', '#pullshelp'], ['#navhome', '/', '#homepage, #nohomepage']];
  778. let navOk = true, navDetail = '';
  779. for (const [link, path, sel] of navSteps) {
  780. await A.click(link);
  781. 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; }
  782. if ((await A.evaluate('window.__navMarker')) !== 42) { navOk = false; navDetail += ` ${path}: page reloaded`; }
  783. if ((await txt(A, '#reponame')) !== 'alpha' || (await A.evaluate('location.host')) !== AH) { navOk = false; navDetail += ` ${path}: wrong repo/host`; }
  784. }
  785. check('navigation Readme → Code → Releases → Tickets → Pulls → Readme: no full page load (window marker survives), the address bar follows, content right', navOk, navDetail);
  786. await A.click('#navcode');
  787. await A.waitFor('location.pathname === "/code" && !!document.querySelector("#entries a.dir")', { label: 'code for folder nav' });
  788. await A.click('#entries a.dir');
  789. await A.waitFor('location.pathname === "/code/src" && !!document.querySelector("#crumbs strong") && document.querySelector("#crumbs strong").textContent === "src"', { label: 'folder via nav' });
  790. 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; })());
  791. // NAVIGATION WHILE LOGGED IN (the creator saw full page loads): a fresh browser, logged in through the button on the
  792. // repo address like a person, then Readme → Code → Releases → Pulls → Tickets → Code → a folder with real clicks
  793. const L = await newPage();
  794. { const [lk, lv] = alice.cookie.split('='); await L.send('Network.enable'); await L.send('Network.setCookie', { name: lk, value: lv, url: IDENT_B + '/' }); }
  795. await L.goto(REPO('alpha') + '/');
  796. await L.waitFor('!!document.querySelector("#loginbutton") && !!document.querySelector("#reponame")', { label: 'L: repo, signed out' });
  797. await hydrated(L);
  798. await buttonLogin(L);
  799. await L.waitFor(`location.href === ${J(REPO('alpha') + '/')} && !!document.querySelector("#whoami") && !!document.querySelector("#homepage")`, { label: 'L: back logged in' });
  800. await hydrated(L);
  801. await L.evaluate('window.__navMarker = 99');
  802. // 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 flight
  803. const 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']];
  804. let liOk = true, liDetail = '';
  805. for (const [link, path, sel] of loggedSteps) {
  806. await L.click(link);
  807. 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; }
  808. if ((await L.evaluate('window.__navMarker')) !== 99) { liOk = false; liDetail += ` ${path}: FULL PAGE LOAD`; await L.evaluate('window.__navMarker = 99'); await hydrated(L); }
  809. if ((await txt(L, '#reponame')) !== 'alpha' || (await txt(L, '#whoami')) !== 'alice') { liOk = false; liDetail += ` ${path}: wrong repo/user`; }
  810. }
  811. 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);
  812. // the same on an EMPTY repo the user owns (like the creator's live repos: no commit yet)
  813. const emptySteps = [['#navcode', '/code', '#codemessage'], ['#navreleases', '/releases', '#releasesmessage'], ['#navpulls', '/pulls', '#pullsmessage'], ['#navtickets', '/tickets', '#ticketsnotconnected'], ['#navhome', '/', '#nohomepage']];
  814. await L.goto(REPO('gamma') + '/');
  815. await L.waitFor('!!document.querySelector("#nohomepage") && !!document.querySelector("#whoami")', { label: 'L: gamma' });
  816. await hydrated(L);
  817. await L.evaluate('window.__navMarker = 98');
  818. let emOk = true, emDetail = '';
  819. for (const [link, path, sel] of emptySteps) {
  820. await L.click(link);
  821. 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; }
  822. if ((await L.evaluate('window.__navMarker')) !== 98) { emOk = false; emDetail += ` ${path}: FULL PAGE LOAD`; await L.evaluate('window.__navMarker = 98'); await hydrated(L); }
  823. }
  824. check('navigation LOGGED IN on an empty repo the user owns: Code → Releases → Pulls → Tickets → Readme, no full page load', emOk, emDetail);
  825. // the socket gone (Cloudflare closes an idle WebSocket after ~100 s): navigation rides the POST fallback
  826. await L.goto(REPO('alpha') + '/');
  827. await L.waitFor('!!document.querySelector("#homepage") && !!document.querySelector("#whoami")', { label: 'L: alpha again' });
  828. await hydrated(L);
  829. await L.evaluate('window.__hl.socket.close(); window.__navMarker = 97');
  830. await L.waitFor('!window.__hl.socket', { label: 'L: socket gone' });
  831. let pfOk = true, pfDetail = '';
  832. for (const [link, path, sel] of [['#navcode', '/code', '#entries'], ['#navreleases', '/releases', '#releaseshelp'], ['#navhome', '/', '#homepage']]) {
  833. await L.click(link);
  834. 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; }
  835. if ((await L.evaluate('window.__navMarker')) !== 97) { pfOk = false; pfDetail += ` ${path}: FULL PAGE LOAD`; break; }
  836. }
  837. check('navigation LOGGED IN with the socket closed (POST fallback): no full page load, still logged in', pfOk, pfDetail);
  838. // THE SAME IN A REAL FIREFOX (the creator's browser; tests/firefox.mjs, WebDriver BiDi, real pointer clicks)
  839. ff = await launchFirefox({ port: FIREFOX_PORT, hosts: ['gitoria.test', 'alpha.gitoria.test', 'beta-2.gitoria.test', 'gamma.gitoria.test', 'ident.gitoria.test'] });
  840. { const [fk, fv] = alice.cookie.split('='); await ff.setCookie(fk, fv, 'ident.gitoria.test'); }
  841. const ffLive = () => ff.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'firefox: page hydrated' });
  842. await ff.goto(REPO('alpha') + '/');
  843. await ff.waitFor('!!document.querySelector("#loginbutton") && !!document.querySelector("#reponame")', { label: 'firefox: repo, signed out' });
  844. await ffLive();
  845. await ff.click('#loginbutton');
  846. await ff.waitFor('!!document.querySelector("#chooselist li .choose")', { label: 'firefox: ident chooser' });
  847. await ffLive();
  848. await ff.click('#chooselist li:nth-child(1) .choose');
  849. await ff.waitFor(`location.href === ${J(REPO('alpha') + '/')} && !!document.querySelector("#whoami") && !!document.querySelector("#homepage")`, { label: 'firefox: back logged in', timeout: 30000 });
  850. await ffLive();
  851. await ff.evaluate('window.__navMarker = 99');
  852. let ffOk = true, ffDetail = '';
  853. for (const [link, path, sel] of loggedSteps) {
  854. await ff.click(link);
  855. 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; }
  856. if ((await ff.evaluate('window.__navMarker')) !== 99) { ffOk = false; ffDetail += ` ${path}: FULL PAGE LOAD`; await ff.evaluate('window.__navMarker = 99'); await ffLive(); }
  857. if ((await ff.evaluate('document.querySelector("#reponame").textContent + "/" + document.querySelector("#whoami").textContent')) !== 'alpha/alice') { ffOk = false; ffDetail += ` ${path}: wrong repo/user`; }
  858. }
  859. 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);
  860. await ff.goto(REPO('gamma') + '/');
  861. await ff.waitFor('!!document.querySelector("#nohomepage") && !!document.querySelector("#whoami")', { label: 'firefox: gamma' });
  862. await ffLive();
  863. await ff.evaluate('window.__navMarker = 98');
  864. let ffeOk = true, ffeDetail = '';
  865. for (const [link, path, sel] of emptySteps) {
  866. await ff.click(link);
  867. 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; }
  868. if ((await ff.evaluate('window.__navMarker')) !== 98) { ffeOk = false; ffeDetail += ` ${path}: FULL PAGE LOAD`; await ff.evaluate('window.__navMarker = 98'); await ffLive(); }
  869. }
  870. check('FIREFOX, navigation LOGGED IN on an empty repo the user owns, no full page load', ffeOk, ffeDetail);
  871. check('FIREFOX: no console errors', ff.errors.length === 0, ff.errors.join(' | '));
  872. await ff.close(); ff = null;
  873. await A.goto(BASE + '/');
  874. await A.waitFor('document.querySelectorAll("#repos li").length === 3', { label: 'list with gamma' });
  875. 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'));
  876. // ---- layout ------------------------------------------------------------------------------------------------
  877. for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {
  878. await viewport(A, w, h);
  879. await A.goto(BASE + '/');
  880. await A.waitFor('document.querySelectorAll("#repos li").length === 3', { label: 'list for layout' });
  881. check(`layout ${w}px: main address has no horizontal overflow`, await noOverflow(A));
  882. await shot(A, `${name}-main`);
  883. await A.goto(REPO('alpha') + '/');
  884. await A.waitFor('!!document.querySelector("#reponame")');
  885. check(`layout ${w}px: repo address has no horizontal overflow`, await noOverflow(A));
  886. await shot(A, `${name}-repo`);
  887. }
  888. // ---- the identity selector (gitoria#14): choose an identity on the main address, no reload ------------------------
  889. const C = await newPage();
  890. // alice is already signed in to ident (over REST, ident allows only 3 codes per address): hand C that ident session
  891. const [ck, cv] = alice.cookie.split('=');
  892. await C.send('Network.enable');
  893. await C.send('Network.setCookie', { name: ck, value: cv, url: IDENT_B + '/' });
  894. await C.goto(BASE + '/');
  895. await C.waitFor('!!document.querySelector("#heading")', { label: 'C: main address' });
  896. await hydrated(C);
  897. await C.evaluate('window.__loginMarker = 1');
  898. // hybriel#43: a second tab of the SAME browser (same session) on a code view — the login/logout flip of the shell
  899. // must leave the page in the slot alone (no re-creation, no "Loading")
  900. const C2 = patient(await browsers[browsers.length - 1].newPage());
  901. await C2.goto(REPO('alpha') + '/code');
  902. await C2.waitFor('!!document.querySelector("#entries") && !!document.querySelector("#loginbutton")', { label: 'C2: code view' });
  903. await hydrated(C2);
  904. await C2.evaluate('window.__flipMarker = 7; document.querySelector("#entries").dataset.keep = "1"');
  905. 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)');
  906. 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'); })()`));
  907. await shClick(C, '#choose');
  908. await C.waitFor(sh(`r.querySelectorAll('[part~="identity"]').length > 0`), { label: 'selector list' });
  909. await shClick(C, '[part~="identity"]', 'Default');
  910. await C.waitFor('!!document.querySelector("#logout")', { label: 'C: logged in after the selector login' });
  911. 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')));
  912. await C.waitFor('!!document.querySelector("#createform")', { label: 'C: create form after the selector login' });
  913. check('selector login: the page follows (create form), the same session as after the button', await has(C, '#createform') && (await txt(C, '#whoami')) === 'alice');
  914. await C2.waitFor('!!document.querySelector("#whoami")', { label: 'C2: logged in by the other tab' });
  915. 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)'));
  916. await C.click('#logout');
  917. await C.waitFor('!!document.querySelector("#loginbutton")', { label: 'C: logged out' });
  918. await C.waitFor(sh(`/choose/.test(r.querySelector('#choose').textContent)`), { label: 'selector reset' });
  919. await C2.waitFor('!!document.querySelector("#loginbutton")', { label: 'C2: logged out by the other tab' });
  920. check('hybriel#43: … and back to logged out, the code still stays', await codeKept(), await C2.evaluate('document.body.innerText.slice(0, 300)'));
  921. 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);
  922. await C.goto(REPO('alpha') + '/');
  923. await C.waitFor('!!document.querySelector("#loginbutton") && !!document.querySelector("#reponame")', { label: 'C: repo address' });
  924. await hydrated(C);
  925. const repoSel = await firstHtml('/', `alpha.gitoria.test:${PORT}`);
  926. 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'));
  927. const loginFx = await fetch(API + '/login.js');
  928. check('/login.js is served', loginFx.status === 200 && /ident-login/.test(await loginFx.text()));
  929. // ---- logout, then the data survives a restart --------------------------------------------------------------
  930. await A.goto(BASE + '/');
  931. await A.waitForSelector('#logout');
  932. await hydrated(A);
  933. await A.click('#logout');
  934. await A.waitFor('!!document.querySelector("#loginbutton") && !document.querySelector("#createform")', { label: 'logged out' });
  935. await A.waitFor('!!document.querySelector("#loginhint")', { label: 'A: list after the logout' });
  936. 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)'));
  937. await stopServer();
  938. startServer(env);
  939. await serverUp();
  940. const again = await (await fetch(API + '/api/repos')).json();
  941. 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));
  942. 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'))));
  943. const problems = [A, B, C, C2, S, L].flatMap(p => p.problems().filter(m => !/favicon|ERR_|Failed to load resource/.test(m.text)));
  944. check('browsers: no console errors', problems.length === 0, problems.map(m => m.text).join(" | "));
  945. 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(" | "));
  946. // ==== mission 046: THE INSTALLABLE APP — hl:web's own manifest + service worker (project.hl appIcons / offline =
  947. // [ Index ]). Last, so every check above ran on the gate's usual Chromes: those are closed now and ONE fresh Chrome
  948. // treats the two test origins as secure (a service worker needs a secure context; plain-http *.gitoria.test is not
  949. // one — https / localhost are, so the live site needs no flag). The manifest and every icon over HTTP; Chrome's own
  950. // verdict; the worker registered and controlling; then OFFLINE: the tab loses the network (the note appears), the
  951. // server is stopped too (CDP's offline emulation does not reach the worker's own fetches — measured in tracker's
  952. // gate — so only a dead server proves the cache), and `/` still opens from the cache: header + note + the list as
  953. // last seen. A data page gets hl:web's "Unavailable offline" page. The same on a repo address (its own origin).
  954. const html = await (await fetch(API + '/')).text();
  955. const mhref = (html.match(/<link rel="manifest" href="([^"]+)"/) || [])[1];
  956. check('pwa: every page head links the manifest, the apple-touch-icon, the favicon and the theme colour (the token `darker`)',
  957. mhref === '/__hl/manifest.webmanifest' && html.includes('<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">')
  958. && html.includes('<link rel="icon" href="/icons/favicon.svg">') && html.includes('<meta name="theme-color" content="rgb(15, 20, 25)">'), html.slice(0, 900));
  959. const mres = await fetch(API + mhref);
  960. const man = await mres.json();
  961. check('pwa: the manifest is served as application/manifest+json: name "gitoria", start_url /, scope /, display standalone, the token colours',
  962. /manifest\+json/.test(mres.headers.get('content-type') || '') && man.name === 'gitoria' && man.short_name === 'gitoria' && man.start_url === '/' && man.scope === '/'
  963. && man.display === 'standalone' && man.theme_color === 'rgb(15, 20, 25)' && man.background_color === 'rgb(25, 30, 35)', J(man));
  964. const iconOk = [];
  965. for (const ic of man.icons || []) {
  966. const ir = await fetch(API + ic.src);
  967. const b = Buffer.from(await ir.arrayBuffer());
  968. 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]));
  969. }
  970. check('pwa: every manifest icon loads and is a real PNG of its declared size (192 + 512, any + maskable)',
  971. 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));
  972. const touch = await fetch(API + '/icons/apple-touch-icon.png');
  973. const touchBytes = Buffer.from(await touch.arrayBuffer());
  974. const fav = await fetch(API + '/icons/favicon.svg');
  975. const favText = await fav.text();
  976. const ico = await fetch(API + '/favicon.ico');
  977. const icoBytes = Buffer.from(await ico.arrayBuffer());
  978. check('pwa: apple-touch-icon is a 180×180 PNG, the favicon an SVG in gitoria blue, /favicon.ico a real icon file',
  979. touch.ok && touchBytes.readUInt32BE(16) === 180 && fav.ok && /image\/svg\+xml/.test(fav.headers.get('content-type') || '') && favText.includes('#569bd4')
  980. && ico.ok && icoBytes.readUInt32BE(0) === 0x00000100, `${touch.status} ${fav.status} ${ico.status}`);
  981. const repoMan = await (await fetch(API + '/__hl/manifest.webmanifest', { headers: { Host: `alpha.gitoria.test:${PORT}` } })).json();
  982. check('pwa: a repo address serves the same manifest (its own origin, start_url / = its Readme)', repoMan.name === 'gitoria' && repoMan.start_url === '/', J(repoMan));
  983. for (const b of browsers) { try { await b.close(); } catch {} }
  984. browsers.length = 0;
  985. process.env.HL_CHROME_ARGS += `|--unsafely-treat-insecure-origin-as-secure=${BASE},${REPO('alpha')}`;
  986. const P = await newPage();
  987. const pev = (e) => P.evaluate(e);
  988. await P.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
  989. const workerOn = async (label) => {
  990. await hydrated(P);
  991. await P.waitFor(`navigator.serviceWorker.getRegistration().then(r => !!(r && r.active))`, { timeout: 15000, label: label + ': service worker active' });
  992. await P.waitFor(`!!navigator.serviceWorker.controller`, { timeout: 10000, label: label + ': page controlled by the worker' });
  993. };
  994. await P.goto(BASE + '/');
  995. await P.waitFor('document.querySelectorAll("#repos li").length === 3', { label: 'pwa: list' });
  996. await workerOn('main');
  997. const inst = await P.send('Page.getInstallabilityErrors');
  998. check('pwa: Chrome reports no installability error', (inst.installabilityErrors || []).length === 0, J(inst));
  999. const am = await P.send('Page.getAppManifest');
  1000. check('pwa: Chrome parses the manifest without errors', am.url && am.url.endsWith('/__hl/manifest.webmanifest') && (am.errors || []).length === 0, J(am.errors));
  1001. check('pwa: hl:web\'s service worker is registered for the whole app (scope /) and controls the page',
  1002. (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')));
  1003. check('pwa: online, no offline note', !(await has(P, '#offline')));
  1004. await shot(P, 'pwa-phone-online');
  1005. // a repo address: its own origin → its own worker, `/` (the Readme) kept there too
  1006. await P.goto(REPO('alpha') + '/');
  1007. await P.waitFor('!!document.querySelector("#reponame")', { label: 'pwa: alpha readme' });
  1008. await workerOn('alpha');
  1009. 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} /`);
  1010. await P.goto(BASE + '/');
  1011. await P.waitFor('document.querySelectorAll("#repos li").length === 3', { label: 'pwa: list again' });
  1012. await hydrated(P);
  1013. await P.setOffline(true);
  1014. await P.waitFor(`!!document.querySelector('#offline')`, { timeout: 5000, label: 'offline note (live)' }).catch(() => {});
  1015. 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.');
  1016. await stopServer();
  1017. let down = false;
  1018. try { await fetch(API + '/api/repos'); } catch { down = true; }
  1019. await P.goto(BASE + '/');
  1020. await P.waitFor(`!!document.querySelector('#offline')`, { timeout: 8000, label: 'offline note after reload' }).catch(() => {});
  1021. 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',
  1022. down && (await txt(P, 'application-header .brand')) === 'gitoria' && await has(P, '#offline')
  1023. && (await pev('document.querySelectorAll("#repos li").length')) === 3
  1024. && (await pev(`getComputedStyle(document.querySelector('application-header')).backgroundColor`)) === 'rgb(15, 20, 25)', 'server down: ' + down + ' ' + (await pev('document.body.innerText.slice(0, 200)')));
  1025. await shot(P, 'pwa-phone-offline');
  1026. await P.goto(REPO('alpha') + '/');
  1027. await P.waitFor(`!!document.querySelector('#offline')`, { timeout: 8000, label: 'offline note on alpha' }).catch(() => {});
  1028. 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)'));
  1029. await P.goto(REPO('alpha') + '/code');
  1030. 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');
  1031. await P.setOffline(false);
  1032. startServer(env);
  1033. await serverUp();
  1034. await P.goto(BASE + '/');
  1035. await hydrated(P);
  1036. await P.waitFor(`!document.querySelector('#offline')`, { timeout: 5000, label: 'note gone' }).catch(() => {});
  1037. check('back online: / from the server again, no offline note', !(await has(P, '#offline')) && (await pev('document.querySelectorAll("#repos li").length')) === 3);
  1038. } catch (e) {
  1039. failures++;
  1040. console.log('FAIL gate crashed — ' + (e && e.stack || e));
  1041. } finally {
  1042. for (const b of browsers) { try { await b.close(); } catch {} }
  1043. if (ff) { try { await ff.close(); } catch {} }
  1044. await stopServer();
  1045. if (tickets) await tickets.stop();
  1046. if (ident) await ident.stop();
  1047. writeFileSync(join(SCRATCH, 'gate-server.log'), log);
  1048. console.log(`${passes} passed, ${failures} failed`);
  1049. process.exit(failures ? 1 : 0);
  1050. }

Branches

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