gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit4a2d71254a2d7125initial commitmre4a2d7125/tests/browser.mjs

75.6 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 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')));
  37. 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')));
  38. rmSync(STORE, { recursive: true, force: true });
  39. mkdirSync(STORE, { recursive: true });
  40. let failures = 0, passes = 0;
  41. function check(label, ok, detail = '') {
  42. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  43. if (ok) passes++; else failures++;
  44. }
  45. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  46. const J = JSON.stringify;
  47. const XSS = '</script><b id="xss">x</b><script>window.__xss=1</script>';
  48. let log = '';
  49. let server = null, ident = null, tickets = null, ff = null;
  50. const browsers = [];
  51. function startServer(extraEnv = {}) {
  52. log += '\n==== gitoria server start\n';
  53. server = spawn(BIN, ['project.hl'], {
  54. cwd: APP,
  55. 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'),
  56. GITORIA_PUBLIC_URL: BASE, IDENT_URL: IDENT_B, IDENT_EXCHANGE_URL: ident.base, ...extraEnv },
  57. stdio: ['ignore', 'pipe', 'pipe'],
  58. });
  59. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  60. }
  61. async function serverUp() {
  62. for (let i = 0; i < 80; i++) { try { const r = await fetch(API + '/api/repos'); if (r.ok) return; } catch {} await sleep(250); }
  63. throw new Error('gitoria did not come up\n' + log);
  64. }
  65. async function stopServer() {
  66. if (!server) return;
  67. try { server.kill('SIGTERM'); } catch {}
  68. await new Promise(r => { if (server.exitCode !== null || server.signalCode !== null) return r(); server.once('exit', r); setTimeout(r, 3000); });
  69. server = null;
  70. }
  71. const SLOW = Number(process.env.GITORIA_GATE_SLOW || 3);
  72. function patient(page) {
  73. const waitFor = page.waitFor.bind(page);
  74. page.waitFor = (expr, o = {}) => waitFor(expr, { ...o, timeout: (o.timeout || 10000) * SLOW });
  75. const goto = page.goto.bind(page);
  76. page.goto = (url, o = {}) => goto(url, { ...o, timeout: (o.timeout || 15000) * SLOW });
  77. return page;
  78. }
  79. async function newPage() {
  80. const b = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });
  81. browsers.push(b);
  82. return patient(await b.newPage());
  83. }
  84. async function viewport(page, width, height) {
  85. await page.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 });
  86. await sleep(250);
  87. }
  88. async function shot(page, name) {
  89. const { data } = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
  90. writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64'));
  91. }
  92. const noOverflow = (page) => page.evaluate('document.documentElement.scrollWidth <= window.innerWidth');
  93. const hydrated = (page) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'page hydrated' });
  94. const txt = (page, sel) => page.evaluate(`(document.querySelector(${J(sel)}) || {}).textContent || null`);
  95. const has = (page, sel) => page.evaluate(`!!document.querySelector(${J(sel)})`);
  96. // INSIDE ident's selector (shadow DOM): an expression over its root `r`, and a REAL click (as in tickets' gate)
  97. const sh = (expr) => `(() => { const h = document.querySelector('#selector'); const r = h && h.shadowRoot; if (!r) return null; return (${expr}); })()`;
  98. async function shClick(page, inner, name = null) {
  99. 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 }; })()`;
  100. const box = await page.waitFor(find, { label: 'selector ' + inner + ' ' + (name || '') });
  101. const at = { x: Math.round(box.x), y: Math.round(box.y), button: 'left', clickCount: 1 };
  102. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...at, buttons: 0 });
  103. await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...at, buttons: 1 });
  104. await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...at, buttons: 0 });
  105. }
  106. async function identSignIn(page, email) {
  107. await page.goto(IDENT_B + '/');
  108. await page.waitForSelector('#email');
  109. await hydrated(page);
  110. await page.type('#email', email);
  111. await page.click('#sendcode');
  112. // ident#20: a sent code NAVIGATES to ident's /code page; type only once that page is hydrated
  113. await page.waitFor('/\\/code$/.test(location.pathname) && !!document.querySelector("#code")', { label: 'ident /code page' });
  114. await hydrated(page);
  115. await page.type('#code', ident.lastCode(email));
  116. await page.click('#verify');
  117. await page.waitForSelector('#signout', { timeout: 10000 });
  118. }
  119. // the login button of the page we are on → ident's "choose an identity" → back
  120. async function buttonLogin(page) {
  121. await page.click('#loginbutton');
  122. await page.waitForSelector('#chooselist', { timeout: 10000 });
  123. await hydrated(page);
  124. await page.click('#chooselist li:nth-child(1) .choose');
  125. }
  126. async function nameIt(page, name) {
  127. await page.waitForSelector('#nameform');
  128. await hydrated(page);
  129. await page.type('#displayname', name);
  130. await page.click('#namesave');
  131. await page.waitFor('!document.querySelector("#nameform")', { label: 'name saved' });
  132. }
  133. async function createRepo(page, slug, description) {
  134. await page.evaluate('document.querySelector("#slug").value = ""; document.querySelector("#description").value = ""');
  135. await page.type('#slug', slug);
  136. if (description) await page.type('#description', description);
  137. await page.click('#createsave');
  138. }
  139. // the FIRST HTML of a page as the server sends it, for any Host (node's fetch cannot set Host) — gitoria#16
  140. const firstHtml = (path, host, cookie) => new Promise((res, rej) => {
  141. const rq = httpRequest({ host: '127.0.0.1', port: PORT, path, headers: { host, ...(cookie ? { cookie } : {}) } }, (r) => {
  142. let b = ''; r.setEncoding('utf8'); r.on('data', d => b += d); r.on('end', () => res({ status: r.statusCode, headers: r.headers, body: b }));
  143. });
  144. rq.on('error', rej); rq.end();
  145. });
  146. const bodyText = (html) => ((html.match(/<body>([\s\S]*)<\/body>/) || [])[1] || '').replace(/<script[\s\S]*?<\/script>/g, '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');
  147. const emitFace = async (event, payload, cookie) => {
  148. const r = await fetch(API + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: 1, event, payload }) });
  149. const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch {}
  150. return { status: r.status, raw: t, value: j && j.value };
  151. };
  152. try {
  153. // ---- our own ident; gitoria is registered with ONE origin: the main address ------------------------
  154. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });
  155. const alice = await ident.signIn('[email protected]');
  156. const gateAcct = await ident.signIn('[email protected]');
  157. const APPKEY = await ident.registerApp(alice, 'gitoria (gate)', [BASE]);
  158. 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')));
  159. // our own tickets (a copy without .env), with a token for its user "gitoria" — gitoria opens tickets with it
  160. tickets = await startTickets({ ticketsDir: TICKETS_DIR, workDir: join(STORE, 'tickets'), port: TICKETS_PORT, ident, who: alice });
  161. 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')));
  162. const env = { IDENT_API_KEY: APPKEY.key, IDENT_API_SECRET: APPKEY.secret, GITORIA_TICKETS_URL: tickets.base, GITORIA_TICKETS_TOKEN: tickets.token };
  163. startServer(env);
  164. await serverUp();
  165. // ---- the API (reads are public) --------------------------------------------------------------
  166. let r = await fetch(API + '/api/repos');
  167. check('api: GET /api/repos is empty at the start', r.status === 200 && J(await r.json()) === '{"repos":[]}');
  168. r = await fetch(API + '/api/repos', { method: 'POST', body: '{}' });
  169. check('api: POST /api/repos → 405 (creating is a web action)', r.status === 405);
  170. r = await fetch(API + '/api/repos/nothing');
  171. check('api: unknown repo → 404', r.status === 404);
  172. r = await fetch(API + '/api/repos', { headers: { accept: 'text/markdown' } });
  173. check('api: Markdown read view of the list', /text\/markdown/.test(r.headers.get('content-type')) && /^# Repositories \(0\)/.test(await r.text()));
  174. // ---- the login button's server half: where does ?next= lead? ------------------------------------
  175. const cbCookie = 'gitoriasid=' + 'ab'.repeat(16);
  176. // a failed login is a PAGE now (components/loginfailed.hl, gitoria#16): the reason is parked in the session → /login/failed
  177. const failedPage = async (url) => {
  178. const c = await fetch(url, { redirect: 'manual' });
  179. const ck = (c.headers.get('set-cookie') || '').split(';')[0];
  180. const pg = await firstHtml('/login/failed', `gitoria.test:${PORT}`, ck);
  181. return { status: c.status, location: c.headers.get('location'), cookie: ck, page: bodyText(pg.body), pageStatus: pg.status };
  182. };
  183. let lf = await failedPage(API + '/login/callback');
  184. 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));
  185. lf = await failedPage(API + '/login/callback?ident_code=' + 'ab'.repeat(24));
  186. check('login: an unknown code → /login/failed says "ident refused"', lf.status === 302 && lf.location === '/login/failed' && /ident refused/.test(lf.page), J(lf));
  187. const nexts = [
  188. ['/', '/'], ['/x?y=1', '/x?y=1'], [REPO('alpha') + '/code', REPO('alpha') + '/code'], [REPO('alpha') + '/', REPO('alpha') + '/'], [REPO('alpha'), REPO('alpha') + '/'],
  189. ['//evil.example', '/'], ['http://evil.example/', '/'], [REPO('www') + '/', '/'], [`http://alpha.gitoria.test:${PORT}.evil.example/`, '/'],
  190. [REPO('alpha') + '/a b', '/'], [REPO('a--b') + '/', '/'], [REPO('Alpha') + '/', '/'], ['/x\r\nSet-Cookie: a=b', '/'], ['/login/callback', '/'], ['', '/'],
  191. [`https://alpha.gitoria.test:${PORT}/`, '/'], [`http://x.y.gitoria.test:${PORT}/`, '/'],
  192. ];
  193. let allNext = true, nextDetail = '';
  194. for (const [want, expect] of nexts) {
  195. const code = await ident.selectorCode(gateAcct, APPKEY, BASE);
  196. const c = await fetch(API + '/login/callback?next=' + encodeURIComponent(want) + '&ident_code=' + code, { redirect: 'manual' });
  197. const loc = c.headers.get('location');
  198. if (c.status !== 302 || loc !== expect) { allNext = false; nextDetail += ` [${J(want)} → ${c.status} ${loc}, wanted ${expect}]`; }
  199. }
  200. check('login: ?next= accepts a path or a valid repo address, everything else → /', allNext, nextDetail);
  201. const cookieCode = await ident.selectorCode(gateAcct, APPKEY, BASE);
  202. const cc = await fetch(API + '/login/callback?ident_code=' + cookieCode, { redirect: 'manual' });
  203. const setCookie = cc.headers.get('set-cookie') || '';
  204. 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);
  205. // ---- the re-vendored Hybriel (mission 033): what the dropped local patches did is upstream now ------------
  206. r = await fetch(API + '/__hl/app.css');
  207. const css = await r.text();
  208. 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));
  209. // ---- forged face session: nobody logged in may create --------------------------------------------
  210. let f = await emitFace('gitoriaCreate', ['forged', '', { user: { id: 'x' } }]);
  211. check('face: a forged trailing session argument creates nothing', !(await (await fetch(API + '/api/repos/forged')).ok) && !(f.value && f.value.repo), f.raw);
  212. f = await emitFace('gitoriaCreate', ['anon', '']);
  213. check('face: not logged in → refused with a message', f.value && /log in/.test(f.value.error || ''), f.raw);
  214. // ---- A = alice in a browser -----------------------------------------------------------------------
  215. const A = await newPage();
  216. await identSignIn(A, '[email protected]');
  217. await A.goto(BASE + '/');
  218. await A.waitFor('!!document.querySelector("#heading")', { label: 'main address shows the list' });
  219. await hydrated(A);
  220. 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')));
  221. 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')}`);
  222. await buttonLogin(A);
  223. await A.waitFor(`location.href === ${J(BASE + '/')} && !!document.querySelector("#nameform")`, { label: 'A back with the name prompt' });
  224. check('button: back on the main address, logged in, asked for a display name', await has(A, '#whoami') && !(await has(A, '#loginbutton')));
  225. await nameIt(A, 'alice');
  226. await A.waitForSelector('#createform');
  227. check('named: the "create a repository" form appears', (await txt(A, '#whoami')) === 'alice');
  228. // ---- B = a signed-out viewer of the main address, for the live list ---------------------------------
  229. const B = await newPage();
  230. await B.goto(BASE + '/');
  231. await B.waitForSelector('#empty');
  232. await hydrated(B);
  233. // ---- create ---------------------------------------------------------------------------------------
  234. const errAfter = async (slug, want) => {
  235. await createRepo(A, slug, '');
  236. await A.waitFor(`new RegExp(${J(want)}).test(document.querySelector("#createerror").textContent)`, { label: 'error for ' + slug });
  237. const e = await txt(A, '#createerror');
  238. check(`create: "${slug}" is refused — ${want}`, new RegExp(want).test(e), e);
  239. return e;
  240. };
  241. await errAfter('Bad Slug', 'only have lowercase');
  242. await errAfter('www', 'reserved');
  243. await errAfter('ab-', 'starts and ends');
  244. await errAfter('ab--cd', 'two hyphens');
  245. await errAfter('-abc', 'starts and ends');
  246. check('create: nothing was stored by the refusals', J(await (await fetch(API + '/api/repos')).json()) === '{"repos":[]}');
  247. await A.evaluate('document.querySelector("#createerror").textContent = ""');
  248. await createRepo(A, 'alpha', 'The first repository');
  249. await A.waitForSelector('#created');
  250. 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') + '/');
  251. 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'));
  252. await B.waitFor('document.querySelectorAll("#repos li").length === 1', { label: 'B sees alpha live' });
  253. check('live: the signed-out viewer B sees alpha without a reload', /alpha/.test(await txt(B, '#repos')) && !(await has(B, '#empty')));
  254. await errAfter('alpha', 'taken');
  255. await A.evaluate('document.querySelector("#createerror").textContent = ""');
  256. await createRepo(A, 'beta-2', '');
  257. await A.waitFor('document.querySelectorAll("#repos li").length === 2', { label: 'A: two repos' });
  258. await B.waitFor('document.querySelectorAll("#repos li").length === 2', { label: 'B: two repos' });
  259. 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');
  260. r = await fetch(API + '/api/repos/alpha');
  261. const alphaRow = await r.json();
  262. 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));
  263. r = await fetch(API + '/api/repos/alpha', { headers: { accept: 'text/markdown' } });
  264. const md = await r.text();
  265. check('api: Markdown read view of a repo', /^# alpha\n/.test(md), md);
  266. // ---- the address: <slug>.gitoria.test ------------------------------------------------------------------
  267. await A.goto(REPO('alpha') + '/');
  268. await A.waitFor('!!document.querySelector("#reponame")', { label: 'alpha at its own address' });
  269. 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'));
  270. check('alpha.<domain>: the login is shared with the main address (same cookie)', (await txt(A, '#whoami')) === 'alice');
  271. check('alpha.<domain>: the title is the repo page, tab title set', (await A.evaluate('document.title')) !== '');
  272. await A.goto(REPO('nothing-here') + '/');
  273. await A.waitFor('!!document.querySelector("#missing")', { label: 'unknown address' });
  274. 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 + '/');
  275. await A.goto(REPO('www') + '/');
  276. await A.waitFor('!!document.querySelector("#missing")', { label: 'reserved address' });
  277. check('a reserved address is no repo either', true);
  278. await A.goto(BASE + '/');
  279. await A.waitFor('!!document.querySelector("#repos li")', { label: 'back on main' });
  280. await A.click('#repos li:nth-child(2) .slug');
  281. await A.waitFor(`location.hostname === 'alpha.gitoria.test' && !!document.querySelector("#reponame")`, { label: 'click on alpha' });
  282. check('clicking a repo in the list opens its own address', (await txt(A, '#reponame')) === 'alpha');
  283. // ---- login FROM a repo address: through the main address and back --------------------------------------
  284. await identSignIn(B, '[email protected]');
  285. await B.goto(REPO('alpha') + '/');
  286. await B.waitFor('!!document.querySelector("#reponame")', { label: 'B at alpha' });
  287. check('signed out at alpha.<domain>: repo visible, login button there', (await has(B, '#loginbutton')) && !(await has(B, '#whoami')));
  288. await hydrated(B);
  289. await buttonLogin(B);
  290. await B.waitFor(`location.href === ${J(REPO('alpha') + '/')} && !!document.querySelector("#whoami")`, { label: 'B logged in back at alpha' });
  291. await B.waitFor('!!document.querySelector("#reponame")', { label: 'B: the repo page after the login' });
  292. 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)'));
  293. await B.goto(BASE + '/');
  294. await B.waitForSelector('#createform');
  295. check('the same login holds on the main address (cookie for all addresses)', (await txt(B, '#whoami')) === 'alice');
  296. // ---- the homepage of a repo (ticket #8): README.md, or the $docs folder --------------------------------------
  297. check('a new repo has an empty bare git repository', existsSync(join(STORE, 'git', 'alpha.git', 'HEAD')), J(existsSync(join(STORE, 'git'))));
  298. const pushFiles = (slug, files) => { // a commit into the bare repo, the way a push would leave it
  299. const work = join(STORE, 'work-' + slug);
  300. rmSync(work, { recursive: true, force: true }); mkdirSync(work, { recursive: true });
  301. const git = (...a) => execFileSync('git', ['-C', work, '-c', 'user.name=t', '-c', '[email protected]', ...a], { stdio: 'pipe' });
  302. git('init', '-q', '-b', 'main');
  303. for (const [p, c] of Object.entries(files)) { mkdirSync(dirname(join(work, p)), { recursive: true }); writeFileSync(join(work, p), c); }
  304. git('add', '-A'); git('commit', '-q', '-m', 'files');
  305. git('push', '-q', '-f', join(STORE, 'git', slug + '.git'), 'main');
  306. };
  307. 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'); };
  308. await A.goto(REPO('alpha') + '/');
  309. check('an empty repo says it has no README.md yet', /no README\.md yet/.test(await homeText(A)));
  310. pushFiles('alpha', {
  311. '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',
  312. 'docs/other.md': '# not the homepage\n',
  313. });
  314. await A.goto(REPO('alpha') + '/');
  315. const alphaText = await homeText(A);
  316. 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));
  317. 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'))); })()`));
  318. pushFiles('alpha', {
  319. 'README.md': '# Root readme\n',
  320. '$docs/b-second.md': '# Second doc\n\nText two.\n',
  321. '$docs/a-first.md': '# First doc\n\nText one.\n',
  322. '$docs/sub/c-third.md': '## Third doc\n',
  323. '$docs/notes.txt': 'not markdown\n',
  324. });
  325. await A.goto(REPO('alpha') + '/');
  326. await homeText(A);
  327. 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)'));
  328. 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)); })());
  329. for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {
  330. await viewport(A, w, h);
  331. 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' });
  332. await A.goto(REPO('beta-2') + '/');
  333. await homeText(A);
  334. check(`layout ${w}px: the homepage has no horizontal overflow`, await noOverflow(A));
  335. await shot(A, `${name}-home`);
  336. }
  337. // ---- browsing code (ticket #9): /code, /branch/<name>, /commit/<id> ------------------------------------------
  338. const gitIn = (work, ...a) => execFileSync('git', ['-C', work, '-c', 'user.name=t', '-c', '[email protected]', ...a], { stdio: 'pipe' }).toString();
  339. const work = join(STORE, 'work-code');
  340. rmSync(work, { recursive: true, force: true }); mkdirSync(join(work, 'src', 'deep'), { recursive: true });
  341. gitIn(work, 'init', '-q', '-b', 'main');
  342. writeFileSync(join(work, 'README.md'), '# Code repo\n');
  343. writeFileSync(join(work, 'src', 'a.txt'), 'line one\n\n indented <b>&amp;</b>\n');
  344. writeFileSync(join(work, 'src', 'deep', 'b.txt'), 'deep file\n');
  345. writeFileSync(join(work, 'my file.txt'), 'spaced name\n');
  346. writeFileSync(join(work, 'bin.dat'), Buffer.from([0, 1, 2, 255, 0, 254, 0, 0]));
  347. gitIn(work, 'add', '-A'); gitIn(work, 'commit', '-q', '-m', 'first commit');
  348. const firstSha = gitIn(work, 'rev-parse', 'HEAD').trim();
  349. writeFileSync(join(work, 'src', 'a.txt'), 'main changed\n');
  350. gitIn(work, 'commit', '-qam', 'second on main');
  351. const mainSha = gitIn(work, 'rev-parse', 'HEAD').trim();
  352. gitIn(work, 'checkout', '-q', '-b', 'feature/x', firstSha);
  353. writeFileSync(join(work, 'src', 'a.txt'), 'feature changed\n');
  354. writeFileSync(join(work, 'only-feature.txt'), 'f\n');
  355. gitIn(work, 'add', '-A'); gitIn(work, 'commit', '-q', '-m', 'feature work');
  356. gitIn(work, 'checkout', '-q', '-b', 'dev', firstSha);
  357. gitIn(work, 'push', '-q', '-f', join(STORE, 'git', 'alpha.git'), 'main', 'feature/x', 'dev');
  358. const codeReady = (p) => p.waitFor('!document.querySelector("#codeloading") && (!!document.querySelector("#codemessage") || !!document.querySelector("#codefile") || !!document.querySelector("#entries") || !!document.querySelector("#noentries"))', { label: 'code answer' });
  359. const names = (p, sel) => p.evaluate(`[...document.querySelectorAll(${J(sel)})].map(x => x.textContent.trim()).join('|')`);
  360. await viewport(A, 1280, 900);
  361. await A.goto(REPO('alpha') + '/code');
  362. await codeReady(A);
  363. 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)'));
  364. 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'));
  365. await A.click('#entries a.dir');
  366. await A.waitFor('location.pathname === "/code/src" && !!document.querySelector("#crumbs strong") && document.querySelector("#crumbs strong").textContent === "src" && !!document.querySelector("#entries")', { label: 'folder opened' });
  367. 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)'));
  368. await A.goto(REPO('alpha') + '/code/src/a.txt');
  369. await codeReady(A);
  370. 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)'));
  371. await A.goto(REPO('alpha') + '/commit/' + firstSha + '/src/a.txt');
  372. await codeReady(A);
  373. 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)'));
  374. await A.goto(REPO('alpha') + '/commit/' + firstSha.slice(0, 7));
  375. await codeReady(A);
  376. 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)'));
  377. await A.goto(REPO('alpha') + '/branch/feature/x');
  378. await codeReady(A);
  379. 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)'));
  380. await A.goto(REPO('alpha') + '/branch/feature/x/src/a.txt');
  381. await codeReady(A);
  382. 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)'));
  383. await A.goto(REPO('alpha') + '/code/my%20file.txt');
  384. await codeReady(A);
  385. check('a file with a space in its name opens', (await names(A, '#lines li')) === 'spaced name');
  386. await A.goto(REPO('alpha') + '/code/bin.dat');
  387. await codeReady(A);
  388. check('a binary file is not shown as text', (await has(A, '#binary')) && !(await has(A, '#lines li')));
  389. 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/]]) {
  390. await A.goto(REPO('alpha') + path);
  391. await codeReady(A);
  392. check(`${path}: a plain message, no crash`, want.test(await txt(A, '#codemessage') || ''), await A.evaluate('document.body.innerText.slice(0, 200)'));
  393. }
  394. await A.goto(BASE + '/');
  395. await A.waitForSelector('#createform');
  396. await hydrated(A);
  397. await createRepo(A, 'gamma', XSS);
  398. await A.waitFor('!!document.querySelector("#created")', { label: 'gamma created' });
  399. await A.goto(REPO('gamma') + '/code');
  400. await codeReady(A);
  401. 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)'));
  402. await A.goto(REPO('alpha') + '/code');
  403. await codeReady(A);
  404. 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'));
  405. await hydrated(A); // the view is in the FIRST HTML now: wait for the page to be live before clicking
  406. await A.click('#branches .setmain');
  407. await A.waitFor('document.querySelector("#coderef") && document.querySelector("#coderef").textContent === "dev"', { label: 'main branch changed' });
  408. 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)'));
  409. await A.goto(REPO('alpha') + '/');
  410. await homeText(A);
  411. check('the homepage README follows the main branch too', await A.evaluate('!!document.querySelector("#homepage")'));
  412. const S = await newPage();
  413. await S.goto(REPO('alpha') + '/code');
  414. await codeReady(S);
  415. 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);
  416. const forged = await emitFace('gitoriaSetBranch', ['alpha', 'main', 'code', '', 'x'], '');
  417. const still = await (await fetch(API + '/api/repos/alpha')).json();
  418. check('face: a stranger cannot change the main branch', still.branch === 'dev', J(forged) + J(still));
  419. for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {
  420. await viewport(A, w, h);
  421. await A.goto(REPO('alpha') + '/code');
  422. await codeReady(A);
  423. check(`layout ${w}px: /code has no horizontal overflow`, await noOverflow(A));
  424. await shot(A, `${name}-code`);
  425. await A.goto(REPO('alpha') + '/code/src/a.txt');
  426. await codeReady(A);
  427. check(`layout ${w}px: a file view has no horizontal overflow of the page`, await noOverflow(A));
  428. await shot(A, `${name}-file`);
  429. }
  430. await viewport(A, 1280, 900);
  431. // ---- the repo's tickets (gitoria#10): /tickets lists tickets.worldapi.org's project, the first ticket makes it ----
  432. const PROJ = 'alpha.gitoria.test';
  433. await A.goto(REPO('alpha') + '/tickets');
  434. await A.waitFor('!!document.querySelector("#noticketsyet")', { label: 'tickets page, no ticket yet' });
  435. check('tickets: /tickets of a repo without tickets says so, has the nav link and the form', (await has(A, '#navtickets')) && (await has(A, '#newticketform')) && !(await has(A, '#ticketlist')));
  436. check('tickets: no project in tickets yet (404) — it is made with the first ticket', (await tickets.api(`/api/projects/${PROJ}/tickets`)).status === 404 && !(await tickets.api('/api/projects')).json.projects.includes(PROJ));
  437. await S.goto(REPO('alpha') + '/tickets');
  438. await S.waitFor('!!document.querySelector("#noticketsyet")', { label: 'S: tickets page' });
  439. await hydrated(S);
  440. check('tickets: a signed-out viewer sees the page with a login hint and no form', (await has(S, '#ticketsloginhint')) && !(await has(S, '#newticketform')));
  441. await hydrated(A);
  442. await A.evaluate('document.querySelector("#newticket").open = true');
  443. await A.type('#ticketsubject', ' ');
  444. await A.click('#ticketsave');
  445. await A.waitFor('!!document.querySelector("#newticketerror").textContent', { label: 'blank subject refused' });
  446. await A.evaluate('document.querySelector("#ticketsubject").value = ""; document.querySelector("#newticket").open = true');
  447. check('tickets: a blank subject is refused in words', /subject/.test(await txt(A, '#newticketerror')));
  448. await A.type('#ticketsubject', 'First <b>ticket</b>');
  449. await A.type('#ticketsummary', 'Some **words**');
  450. await A.click('#ticketsave');
  451. await A.waitFor('document.querySelectorAll("#ticketlist li").length === 1', { label: 'first ticket listed' });
  452. const t1 = (await tickets.api(`/api/projects/${PROJ}/tickets/1`)).json.ticket;
  453. check('tickets: opening the first ticket creates the project in tickets, author = the gitoria user, text says who', t1 && t1.subject === 'First <b>ticket</b>' && t1.state === 'open' && /Some \*\*words\*\*\n\n— opened by alice in gitoria$/.test(t1.summary) && (await tickets.api('/api/projects')).json.projects.includes(PROJ), J(t1));
  454. 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')}; })()`));
  455. check('tickets: the form is cleared and says "Opened ticket #1"', (await A.evaluate('document.querySelector("#ticketsubject").value')) === '' && /#1/.test(await txt(A, '#ticketnotice')));
  456. await S.waitFor('document.querySelectorAll("#ticketlist li").length === 1', { label: 'S sees it live' });
  457. check('live: the signed-out viewer sees the new ticket without a reload', /First/.test(await txt(S, '#ticketlist')) && !(await has(S, '#noticketsyet')));
  458. // a ticket opened in tickets itself, and one with a name only tickets knows: shown on the next load
  459. 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' }) });
  460. check('tickets: a ticket made on tickets.worldapi.org directly → 201', ext.status === 201);
  461. await A.goto(REPO('alpha') + '/tickets');
  462. await A.waitFor('document.querySelectorAll("#ticketlist li").length === 2', { label: 'two tickets after reload' });
  463. 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);
  464. check('tickets: another repo has none of them', (await tickets.api('/api/projects/beta-2.gitoria.test/tickets')).status === 404);
  465. await S.goto(REPO('beta-2') + '/tickets');
  466. await S.waitFor('!!document.querySelector("#noticketsyet")', { label: 'beta-2 has no tickets' });
  467. check('tickets: beta-2 shows its own empty list', !(await has(S, '#ticketlist')));
  468. await S.goto(REPO('nothing-here') + '/tickets');
  469. await S.waitFor('!!document.querySelector("#missing")', { label: 'unknown repo tickets' });
  470. check('tickets: an address nobody created has no tickets page', true);
  471. f = await emitFace('gitoriaOpenTicket', ['alpha', 'anon', '']);
  472. 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);
  473. f = await emitFace('gitoriaOpenTicket', ['alpha', 'forged', '', { user: { id: 'x' } }]);
  474. 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);
  475. f = await emitFace('gitoriaOpenTicket', ['no-such-repo', 'x', '']);
  476. 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);
  477. for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {
  478. await viewport(A, w, h);
  479. await A.goto(REPO('alpha') + '/tickets');
  480. await A.waitFor('document.querySelectorAll("#ticketlist li").length === 2', { label: 'tickets for layout' });
  481. check(`layout ${w}px: /tickets has no horizontal overflow`, await noOverflow(A));
  482. await shot(A, `${name}-tickets`);
  483. }
  484. await viewport(A, 1280, 900);
  485. await tickets.stop();
  486. await S.goto(REPO('alpha') + '/tickets');
  487. await S.waitFor('!!document.querySelector("#ticketserror")', { label: 'tickets down' });
  488. check('tickets down: the page says tickets.worldapi.org did not answer', /did not answer/.test(await txt(S, '#ticketserror')));
  489. tickets = await startTickets({ ticketsDir: TICKETS_DIR, workDir: join(STORE, 'tickets2'), port: TICKETS_PORT, ident, who: alice });
  490. // ---- pull requests (gitoria#11): /pulls lists the commits that start with |||PR -------------------------------
  491. const pw = join(STORE, 'work-pulls');
  492. rmSync(pw, { recursive: true, force: true }); mkdirSync(pw, { recursive: true });
  493. gitIn(pw, 'init', '-q', '-b', 'main');
  494. writeFileSync(join(pw, 'a.txt'), 'base\n'); gitIn(pw, 'add', '-A'); gitIn(pw, 'commit', '-q', '-m', 'base');
  495. const baseSha = gitIn(pw, 'rev-parse', 'HEAD').trim();
  496. 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(); };
  497. gitIn(pw, 'branch', 'dev');
  498. const fixSha = branchCommit('fix-a', '|||PR Fix the <b>thing</b>');
  499. gitIn(pw, 'commit', '-q', '--allow-empty', '-m', 'more work on fix-a');
  500. branchCommit('topic/one', '|||PR|dev] Topic to dev');
  501. branchCommit('ghost', '|||PR|nope] Lost target');
  502. branchCommit('plain', 'not a pull request |||PR');
  503. branchCommit('nospace', '|||PR|main]glued');
  504. branchCommit('old', '|||PR Old work');
  505. gitIn(pw, 'checkout', '-q', 'main'); gitIn(pw, 'merge', '-q', '--ff-only', 'old');
  506. gitIn(pw, 'push', '-q', '-f', join(STORE, 'git', 'beta-2.git'), 'main', 'dev', 'fix-a', 'topic/one', 'ghost', 'plain', 'nospace', 'old');
  507. const pullsReady = (p) => p.waitFor('!document.querySelector("#pullsloading") && (!!document.querySelector("#pullsmessage") || !!document.querySelector("#pullshelp"))', { label: 'pulls answer' });
  508. await A.goto(REPO('beta-2') + '/pulls');
  509. await pullsReady(A);
  510. 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(' ~ '))`);
  511. 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));
  512. 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));
  513. 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('a.subject').href.endsWith('/commit/${fixSha}') && a.querySelector('a.source').pathname === '/branch/fix-a' && a.querySelector('a.target').pathname === '/branch/main'; })() && !!document.querySelector('#navpulls')`));
  514. await A.evaluate(`[...document.querySelectorAll('#pulllist a.source')].find(a => a.textContent === 'fix-a').click()`);
  515. await A.waitFor('location.pathname === "/branch/fix-a"', { label: 'source branch link' });
  516. await S.goto(REPO('beta-2') + '/pulls');
  517. await pullsReady(S);
  518. check('/pulls: a signed-out viewer sees the same list', (await S.evaluate('document.querySelectorAll("#pulllist li").length')) === 4);
  519. await S.goto(REPO('gamma') + '/pulls');
  520. await pullsReady(S);
  521. check('/pulls of an empty repo says it has no commits yet', /no commits yet/.test(await txt(S, '#pullsmessage') || ''));
  522. await S.goto(REPO('alpha') + '/pulls');
  523. await pullsReady(S);
  524. check('/pulls of a repo without marker commits says "No pull request yet"', (await has(S, '#nopulls')) && !(await has(S, '#pulllist li')));
  525. await S.goto(REPO('nothing-here') + '/pulls');
  526. await S.waitFor('!!document.querySelector("#missing")', { label: 'unknown repo pulls' });
  527. const forgedPulls = await emitFace('gitoriaPulls', ['beta-2', 'x', 'evil'], '');
  528. check('face: a forged session gets no pull list', !forgedPulls.value || forgedPulls.value.ok !== true, J(forgedPulls));
  529. for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {
  530. await viewport(A, w, h);
  531. await A.goto(REPO('beta-2') + '/pulls');
  532. await pullsReady(A);
  533. check(`layout ${w}px: /pulls has no horizontal overflow`, await noOverflow(A));
  534. await shot(A, `${name}-pulls`);
  535. }
  536. await viewport(A, 1280, 900);
  537. // ---- merge button (gitoria#17): the owner merges a pull request, only by clicking; a conflict merges nothing ------
  538. const mergeBtns = (p) => p.evaluate(`[...document.querySelectorAll('#pulllist li')].filter(li => li.querySelector('button.mergepr')).map(li => li.querySelector('.subject').textContent).join('|')`);
  539. await A.goto(REPO('beta-2') + '/pulls');
  540. await pullsReady(A);
  541. 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));
  542. await S.goto(REPO('beta-2') + '/pulls');
  543. await pullsReady(S);
  544. check('merge: a signed-out viewer sees no Merge button', (await S.evaluate('document.querySelectorAll("button.mergepr").length')) === 0);
  545. const forgedMerge = await emitFace('gitoriaMergePull', ['beta-2', fixSha, 'evil'], '');
  546. check('merge: a forged session cannot merge', !forgedMerge.value || forgedMerge.value.error != null || forgedMerge.value.result == null, J(forgedMerge));
  547. // a branch that conflicts with main
  548. 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');
  549. 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');
  550. gitIn(pw, 'push', '-q', '-f', join(STORE, 'git', 'beta-2.git'), 'main', 'clash');
  551. const mainBefore = gitIn(join(STORE, 'git', 'beta-2.git'), 'rev-parse', 'main').trim();
  552. await A.goto(REPO('beta-2') + '/pulls');
  553. await pullsReady(A);
  554. const clickMerge = (title) => A.evaluate(`(() => { const li = [...document.querySelectorAll('#pulllist li')].find(l => l.querySelector('.subject').textContent === ${J(title)}); li.querySelector('button.mergepr').click(); })()`);
  555. await clickMerge('Clashing change');
  556. 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')); });
  557. 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);
  558. await clickMerge('Fix the <b>thing</b>');
  559. 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' });
  560. const bare = join(STORE, 'git', 'beta-2.git');
  561. const mainLog = gitIn(bare, 'log', '--format=%s|%an', 'main');
  562. 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);
  563. 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);
  564. 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()`)]));
  565. await S.goto(REPO('beta-2') + '/pulls');
  566. await pullsReady(S);
  567. 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'))`));
  568. await viewport(A, 1280, 900);
  569. // ---- releases (gitoria#12): /releases lists the |||RL commits of the main branch, versions counted up ---------
  570. const rw = join(STORE, 'work-rel');
  571. rmSync(rw, { recursive: true, force: true }); mkdirSync(rw, { recursive: true });
  572. gitIn(rw, 'init', '-q', '-b', 'main');
  573. const rc = (msg) => { gitIn(rw, 'commit', '-q', '--allow-empty', '-m', msg); return gitIn(rw, 'rev-parse', 'HEAD').trim(); };
  574. rc('start');
  575. rc('|||RL First <b>one</b>');
  576. rc('work');
  577. rc('|||RL|med Feature drop');
  578. rc('|||RL|mj Big one');
  579. rc('|||RL');
  580. rc('text |||RL not a release');
  581. rc('|||RL|nonsense x');
  582. rc('|||RL|1.1.1a By hand');
  583. const lastSha = rc('|||RL after the hand one');
  584. rc('|||RL|1.1.1a again the same');
  585. gitIn(rw, 'checkout', '-q', '-b', 'side'); rc('|||RL on a side branch');
  586. gitIn(rw, 'push', '-q', '-f', join(STORE, 'git', 'beta-2.git'), 'main', 'side');
  587. const relReady = (p) => p.waitFor('!document.querySelector("#releasesloading") && (!!document.querySelector("#releasesmessage") || !!document.querySelector("#releaseshelp"))', { label: 'releases answer' });
  588. await A.goto(REPO('beta-2') + '/releases');
  589. await relReady(A);
  590. 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)`);
  591. 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));
  592. 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));
  593. 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')`));
  594. await S.goto(REPO('gamma') + '/releases');
  595. await relReady(S);
  596. check('/releases of an empty repo says it has no commits yet', /no commits yet/.test(await txt(S, '#releasesmessage') || ''));
  597. await S.goto(REPO('alpha') + '/releases');
  598. await relReady(S);
  599. check('/releases of a repo without release commits says "No release yet"', (await has(S, '#noreleases')) && !(await has(S, '#releaselist li')));
  600. const forgedRel = await emitFace('gitoriaReleases', ['beta-2', 'x', 'evil'], '');
  601. check('face: a forged session gets no release list', !forgedRel.value || forgedRel.value.ok !== true, J(forgedRel));
  602. for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {
  603. await viewport(A, w, h);
  604. await A.goto(REPO('beta-2') + '/releases');
  605. await relReady(A);
  606. check(`layout ${w}px: /releases has no horizontal overflow`, await noOverflow(A));
  607. await shot(A, `${name}-releases`);
  608. }
  609. await viewport(A, 1280, 900);
  610. // ---- gitoria#16: every repo view is its own page, rendered on the SERVER for the request's host ---------------
  611. const AH = `alpha.gitoria.test:${PORT}`;
  612. // the git views are read on the server too (hl:proc run(), hybriel#80): the FIRST HTML holds the content, no loading step
  613. const BH = `beta-2.gitoria.test:${PORT}`;
  614. const firstViews = [
  615. ['/', AH, (b, t) => /id="homepage"/.test(b) && /Code repo/.test(t), 'Readme: the README (main branch dev)'],
  616. ['/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'],
  617. ['/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)'],
  618. ['/code/src/a.txt', AH, (b, t) => /id="lines"/.test(b) && /line one/.test(t), 'Code/<file>: the file lines'],
  619. ['/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'],
  620. ['/branch/feature/x', AH, (b, t) => /id="coderef">feature\/x</.test(b) && /only-feature\.txt/.test(t), 'Branch with a slash'],
  621. ['/commit/' + firstSha, AH, (b, t) => /id="codekind"[^>]*>Commit</.test(b) && /id="entries"/.test(b) && /first commit/.test(t), 'Commit: the project at that commit'],
  622. ['/pulls', BH, (b, t) => /id="pulllist"/.test(b) && /Fix the/.test(t), 'Pull requests: the list'],
  623. ['/releases', BH, (b, t) => /id="releaselist"/.test(b) && /1\.1\.2/.test(t) && /after the hand one/.test(t), 'Releases: the list'],
  624. ];
  625. for (const [path, host, ok, what] of firstViews) {
  626. const f = await firstHtml(path, host);
  627. const t = bodyText(f.body);
  628. 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));
  629. }
  630. r = await fetch(API + '/host.js');
  631. check('/host.js is gone (404)', r.status === 404, String(r.status));
  632. let fh, ft;
  633. // (the tickets copy was restarted empty above) one ticket made in tickets itself, then the page from scratch
  634. 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' }) });
  635. fh = await firstHtml('/tickets', AH);
  636. ft = bodyText(fh.body);
  637. 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));
  638. fh = await firstHtml('/', `gitoria.test:${PORT}`);
  639. ft = bodyText(fh.body);
  640. 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));
  641. 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]);
  642. let allMissing = true, missDetail = '';
  643. for (const p of ['/', '/code', '/code/src', '/branch/main', '/commit/abcdef1', '/pulls', '/releases', '/tickets']) {
  644. const m = await firstHtml(p, `nothing-here.gitoria.test:${PORT}`);
  645. 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; }
  646. }
  647. check('unknown repo: every view says "No such repository" in the first HTML', allMissing, missDetail);
  648. // direct load of every view in Chrome
  649. const views = [
  650. ['/', '#homepage, #nohomepage', 'Readme'], ['/code', '#entries', 'Code'], ['/code/src/a.txt', '#lines', 'Code'], ['/branch/feature/x', '#entries', 'Branch'],
  651. ['/commit/' + firstSha.slice(0, 8), '#entries', 'Commit'], ['/pulls', '#pullshelp', 'Pull requests'], ['/releases', '#releaseshelp', 'Releases'], ['/tickets', '#ticketlist', 'Tickets'],
  652. ];
  653. let direct = true, directDetail = '';
  654. for (const [p, sel, title] of views) {
  655. await A.goto(REPO('alpha') + p);
  656. try { await A.waitFor(`!!document.querySelector(${J(sel)}) && !!document.querySelector('#reponame')`, { label: 'direct ' + p, timeout: 6000 }); } catch { direct = false; directDetail += ' ' + p + ' (no ' + sel + ')'; continue; }
  657. const t = await A.evaluate('document.title');
  658. if (!t.startsWith(title === 'Readme' ? 'alpha' : title) || !t.includes('alpha')) { direct = false; directDetail += ` ${p} title ${J(t)}`; }
  659. }
  660. check('direct load of every view (Readme, Code, a file, Branch, Commit, Pulls, Releases, Tickets): its content, its tab title', direct, directDetail);
  661. // hl:web navigation: Readme → Code → Releases → Tickets → Pulls → Readme without a page load
  662. await A.goto(REPO('alpha') + '/');
  663. await A.waitFor('!!document.querySelector("#homepage, #nohomepage")', { label: 'nav start: Readme' });
  664. await hydrated(A);
  665. await A.evaluate('window.__navMarker = 42');
  666. const navSteps = [['#navcode', '/code', '#entries'], ['#navreleases', '/releases', '#releaseshelp'], ['#navtickets', '/tickets', '#ticketlist'], ['#navpulls', '/pulls', '#pullshelp'], ['#navhome', '/', '#homepage, #nohomepage']];
  667. let navOk = true, navDetail = '';
  668. for (const [link, path, sel] of navSteps) {
  669. await A.click(link);
  670. 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; }
  671. if ((await A.evaluate('window.__navMarker')) !== 42) { navOk = false; navDetail += ` ${path}: page reloaded`; }
  672. if ((await txt(A, '#reponame')) !== 'alpha' || (await A.evaluate('location.host')) !== AH) { navOk = false; navDetail += ` ${path}: wrong repo/host`; }
  673. }
  674. check('navigation Readme → Code → Releases → Tickets → Pulls → Readme: no full page load (window marker survives), the address bar follows, content right', navOk, navDetail);
  675. await A.click('#navcode');
  676. await A.waitFor('location.pathname === "/code" && !!document.querySelector("#entries a.dir")', { label: 'code for folder nav' });
  677. await A.click('#entries a.dir');
  678. await A.waitFor('location.pathname === "/code/src" && !!document.querySelector("#crumbs strong") && document.querySelector("#crumbs strong").textContent === "src"', { label: 'folder via nav' });
  679. 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; })());
  680. // NAVIGATION WHILE LOGGED IN (the creator saw full page loads): a fresh browser, logged in through the button on the
  681. // repo address like a person, then Readme → Code → Releases → Pulls → Tickets → Code → a folder with real clicks
  682. const L = await newPage();
  683. { const [lk, lv] = alice.cookie.split('='); await L.send('Network.enable'); await L.send('Network.setCookie', { name: lk, value: lv, url: IDENT_B + '/' }); }
  684. await L.goto(REPO('alpha') + '/');
  685. await L.waitFor('!!document.querySelector("#loginbutton") && !!document.querySelector("#reponame")', { label: 'L: repo, signed out' });
  686. await hydrated(L);
  687. await buttonLogin(L);
  688. await L.waitFor(`location.href === ${J(REPO('alpha') + '/')} && !!document.querySelector("#whoami") && !!document.querySelector("#homepage")`, { label: 'L: back logged in' });
  689. await hydrated(L);
  690. await L.evaluate('window.__navMarker = 99');
  691. 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 strong']];
  692. let liOk = true, liDetail = '';
  693. for (const [link, path, sel] of loggedSteps) {
  694. await L.click(link);
  695. 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; }
  696. if ((await L.evaluate('window.__navMarker')) !== 99) { liOk = false; liDetail += ` ${path}: FULL PAGE LOAD`; await L.evaluate('window.__navMarker = 99'); await hydrated(L); }
  697. if ((await txt(L, '#reponame')) !== 'alpha' || (await txt(L, '#whoami')) !== 'alice') { liOk = false; liDetail += ` ${path}: wrong repo/user`; }
  698. }
  699. 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);
  700. // the same on an EMPTY repo the user owns (like the creator's live repos: no commit yet)
  701. const emptySteps = [['#navcode', '/code', '#codemessage'], ['#navreleases', '/releases', '#releasesmessage'], ['#navpulls', '/pulls', '#pullsmessage'], ['#navtickets', '/tickets', '#noticketsyet'], ['#navhome', '/', '#nohomepage']];
  702. await L.goto(REPO('gamma') + '/');
  703. await L.waitFor('!!document.querySelector("#nohomepage") && !!document.querySelector("#whoami")', { label: 'L: gamma' });
  704. await hydrated(L);
  705. await L.evaluate('window.__navMarker = 98');
  706. let emOk = true, emDetail = '';
  707. for (const [link, path, sel] of emptySteps) {
  708. await L.click(link);
  709. 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; }
  710. if ((await L.evaluate('window.__navMarker')) !== 98) { emOk = false; emDetail += ` ${path}: FULL PAGE LOAD`; await L.evaluate('window.__navMarker = 98'); await hydrated(L); }
  711. }
  712. check('navigation LOGGED IN on an empty repo the user owns: Code → Releases → Pulls → Tickets → Readme, no full page load', emOk, emDetail);
  713. // the socket gone (Cloudflare closes an idle WebSocket after ~100 s): navigation rides the POST fallback
  714. await L.goto(REPO('alpha') + '/');
  715. await L.waitFor('!!document.querySelector("#homepage") && !!document.querySelector("#whoami")', { label: 'L: alpha again' });
  716. await hydrated(L);
  717. await L.evaluate('window.__hl.socket.close(); window.__navMarker = 97');
  718. await L.waitFor('!window.__hl.socket', { label: 'L: socket gone' });
  719. let pfOk = true, pfDetail = '';
  720. for (const [link, path, sel] of [['#navcode', '/code', '#entries'], ['#navreleases', '/releases', '#releaseshelp'], ['#navhome', '/', '#homepage']]) {
  721. await L.click(link);
  722. 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; }
  723. if ((await L.evaluate('window.__navMarker')) !== 97) { pfOk = false; pfDetail += ` ${path}: FULL PAGE LOAD`; break; }
  724. }
  725. check('navigation LOGGED IN with the socket closed (POST fallback): no full page load, still logged in', pfOk, pfDetail);
  726. // THE SAME IN A REAL FIREFOX (the creator's browser; tests/firefox.mjs, WebDriver BiDi, real pointer clicks)
  727. ff = await launchFirefox({ port: FIREFOX_PORT, hosts: ['gitoria.test', 'alpha.gitoria.test', 'beta-2.gitoria.test', 'gamma.gitoria.test', 'ident.gitoria.test'] });
  728. { const [fk, fv] = alice.cookie.split('='); await ff.setCookie(fk, fv, 'ident.gitoria.test'); }
  729. const ffLive = () => ff.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'firefox: page hydrated' });
  730. await ff.goto(REPO('alpha') + '/');
  731. await ff.waitFor('!!document.querySelector("#loginbutton") && !!document.querySelector("#reponame")', { label: 'firefox: repo, signed out' });
  732. await ffLive();
  733. await ff.click('#loginbutton');
  734. await ff.waitFor('!!document.querySelector("#chooselist li .choose")', { label: 'firefox: ident chooser' });
  735. await ffLive();
  736. await ff.click('#chooselist li:nth-child(1) .choose');
  737. await ff.waitFor(`location.href === ${J(REPO('alpha') + '/')} && !!document.querySelector("#whoami") && !!document.querySelector("#homepage")`, { label: 'firefox: back logged in', timeout: 30000 });
  738. await ffLive();
  739. await ff.evaluate('window.__navMarker = 99');
  740. let ffOk = true, ffDetail = '';
  741. for (const [link, path, sel] of loggedSteps) {
  742. await ff.click(link);
  743. 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; }
  744. if ((await ff.evaluate('window.__navMarker')) !== 99) { ffOk = false; ffDetail += ` ${path}: FULL PAGE LOAD`; await ff.evaluate('window.__navMarker = 99'); await ffLive(); }
  745. if ((await ff.evaluate('document.querySelector("#reponame").textContent + "/" + document.querySelector("#whoami").textContent')) !== 'alpha/alice') { ffOk = false; ffDetail += ` ${path}: wrong repo/user`; }
  746. }
  747. 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);
  748. await ff.goto(REPO('gamma') + '/');
  749. await ff.waitFor('!!document.querySelector("#nohomepage") && !!document.querySelector("#whoami")', { label: 'firefox: gamma' });
  750. await ffLive();
  751. await ff.evaluate('window.__navMarker = 98');
  752. let ffeOk = true, ffeDetail = '';
  753. for (const [link, path, sel] of emptySteps) {
  754. await ff.click(link);
  755. 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; }
  756. if ((await ff.evaluate('window.__navMarker')) !== 98) { ffeOk = false; ffeDetail += ` ${path}: FULL PAGE LOAD`; await ff.evaluate('window.__navMarker = 98'); await ffLive(); }
  757. }
  758. check('FIREFOX, navigation LOGGED IN on an empty repo the user owns, no full page load', ffeOk, ffeDetail);
  759. check('FIREFOX: no console errors', ff.errors.length === 0, ff.errors.join(' | '));
  760. await ff.close(); ff = null;
  761. await A.goto(BASE + '/');
  762. await A.waitFor('document.querySelectorAll("#repos li").length === 3', { label: 'list with gamma' });
  763. 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'));
  764. // ---- layout ------------------------------------------------------------------------------------------------
  765. for (const [w, h, name] of [[390, 844, 'narrow'], [1280, 900, 'wide']]) {
  766. await viewport(A, w, h);
  767. await A.goto(BASE + '/');
  768. await A.waitFor('document.querySelectorAll("#repos li").length === 3', { label: 'list for layout' });
  769. check(`layout ${w}px: main address has no horizontal overflow`, await noOverflow(A));
  770. await shot(A, `${name}-main`);
  771. await A.goto(REPO('alpha') + '/');
  772. await A.waitFor('!!document.querySelector("#reponame")');
  773. check(`layout ${w}px: repo address has no horizontal overflow`, await noOverflow(A));
  774. await shot(A, `${name}-repo`);
  775. }
  776. // ---- the identity selector (gitoria#14): choose an identity on the main address, no reload ------------------------
  777. const C = await newPage();
  778. // alice is already signed in to ident (over REST, ident allows only 3 codes per address): hand C that ident session
  779. const [ck, cv] = alice.cookie.split('=');
  780. await C.send('Network.enable');
  781. await C.send('Network.setCookie', { name: ck, value: cv, url: IDENT_B + '/' });
  782. await C.goto(BASE + '/');
  783. await C.waitFor('!!document.querySelector("#heading")', { label: 'C: main address' });
  784. await hydrated(C);
  785. await C.evaluate('window.__loginMarker = 1');
  786. // hybriel#43: a second tab of the SAME browser (same session) on a code view — the login/logout flip of the shell
  787. // must leave the page in the slot alone (no re-creation, no "Loading")
  788. const C2 = patient(await browsers[browsers.length - 1].newPage());
  789. await C2.goto(REPO('alpha') + '/code');
  790. await C2.waitFor('!!document.querySelector("#entries") && !!document.querySelector("#loginbutton")', { label: 'C2: code view' });
  791. await hydrated(C2);
  792. await C2.evaluate('window.__flipMarker = 7; document.querySelector("#entries").dataset.keep = "1"');
  793. 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)');
  794. 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'); })()`));
  795. await shClick(C, '#choose');
  796. await C.waitFor(sh(`r.querySelectorAll('[part~="identity"]').length > 0`), { label: 'selector list' });
  797. await shClick(C, '[part~="identity"]', 'Default');
  798. await C.waitFor('!!document.querySelector("#logout")', { label: 'C: logged in after the selector login' });
  799. 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')));
  800. await C.waitFor('!!document.querySelector("#createform")', { label: 'C: create form after the selector login' });
  801. check('selector login: the page follows (create form), the same session as after the button', await has(C, '#createform') && (await txt(C, '#whoami')) === 'alice');
  802. await C2.waitFor('!!document.querySelector("#whoami")', { label: 'C2: logged in by the other tab' });
  803. 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)'));
  804. await C.click('#logout');
  805. await C.waitFor('!!document.querySelector("#loginbutton")', { label: 'C: logged out' });
  806. await C.waitFor(sh(`/choose/.test(r.querySelector('#choose').textContent)`), { label: 'selector reset' });
  807. await C2.waitFor('!!document.querySelector("#loginbutton")', { label: 'C2: logged out by the other tab' });
  808. check('hybriel#43: … and back to logged out, the code still stays', await codeKept(), await C2.evaluate('document.body.innerText.slice(0, 300)'));
  809. 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);
  810. await C.goto(REPO('alpha') + '/');
  811. await C.waitFor('!!document.querySelector("#loginbutton") && !!document.querySelector("#reponame")', { label: 'C: repo address' });
  812. await hydrated(C);
  813. const repoSel = await firstHtml('/', `alpha.gitoria.test:${PORT}`);
  814. 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'));
  815. const loginFx = await fetch(API + '/login.js');
  816. check('/login.js is served', loginFx.status === 200 && /ident-login/.test(await loginFx.text()));
  817. // ---- logout, then the data survives a restart --------------------------------------------------------------
  818. await A.goto(BASE + '/');
  819. await A.waitForSelector('#logout');
  820. await hydrated(A);
  821. await A.click('#logout');
  822. await A.waitFor('!!document.querySelector("#loginbutton") && !document.querySelector("#createform")', { label: 'logged out' });
  823. await A.waitFor('!!document.querySelector("#loginhint")', { label: 'A: list after the logout' });
  824. 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)'));
  825. await stopServer();
  826. startServer(env);
  827. await serverUp();
  828. const again = await (await fetch(API + '/api/repos')).json();
  829. 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));
  830. 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'))));
  831. const problems = [A, B, C, C2, S, L].flatMap(p => p.problems().filter(m => !/favicon|ERR_|Failed to load resource/.test(m.text)));
  832. check('browsers: no console errors', problems.length === 0, problems.map(m => m.text).join(" | "));
  833. 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(" | "));
  834. } catch (e) {
  835. failures++;
  836. console.log('FAIL gate crashed — ' + (e && e.stack || e));
  837. } finally {
  838. for (const b of browsers) { try { await b.close(); } catch {} }
  839. if (ff) { try { await ff.close(); } catch {} }
  840. await stopServer();
  841. if (tickets) await tickets.stop();
  842. if (ident) await ident.stop();
  843. writeFileSync(join(SCRATCH, 'gate-server.log'), log);
  844. console.log(`${passes} passed, ${failures} failed`);
  845. process.exit(failures ? 1 : 0);
  846. }

Branches

Latest commits

  • 4a2d7125initial commitmre