OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / repos-page-more-e2e.test.js

166 lines · javascript ref:
// /repos + GET /api/repos/page over real HTTP, against a real daemon.
//
// The unit tests cover the slicing contract; this covers the wiring the "view
// more" control actually depends on — that the landing page ships a bounded
// first page with the paging state the client reads off #repo-more, and that
// walking the fragment route from that state yields the rest of the fleet
// exactly once, in the same order.

import test from 'brittle'
import process from '#node:process'
import http from '#node:http'
import { mkdtempSync } from '#node:fs'
import { mkdir, writeFile } from '#node:fs/promises'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import b4a from 'b4a'

import { startPearGit } from '../index.js'
import { execFileSync } from '../lib/_compat-exec.js'
import { PAGE_SIZE } from '../lib/web/pages/repos.js'

process.env.PGV3_USE_RAW_IDENTITY = '1'
const tmp = (p) => mkdtempSync(join(tmpdir(), p))

// `fetch` is not a global under Bare — go through #node:http like the other
// HTTP tests in this suite.
function httpGet (url) {
  return new Promise((resolve, reject) => {
    const u = new URL(url)
    const req = http.request(
      { method: 'GET', hostname: u.hostname, port: u.port, path: u.pathname + u.search },
      (res) => {
        const parts = []
        res.on('data', (c) => parts.push(c))
        res.on('end', () => resolve({ status: res.statusCode, body: b4a.toString(b4a.concat(parts), 'utf8') }))
        res.on('error', reject)
      }
    )
    req.on('error', reject)
    req.end()
  })
}

const getJson = async (url) => {
  const r = await httpGet(url)
  return { status: r.status, body: JSON.parse(r.body) }
}

function git (cwd, args) {
  return execFileSync('git', args, {
    cwd,
    stdio: ['ignore', 'pipe', 'ignore'],
    env: {
      ...process.env,
      GIT_AUTHOR_NAME: 't',
      GIT_AUTHOR_EMAIL: 't@t',
      GIT_COMMITTER_NAME: 't',
      GIT_COMMITTER_EMAIL: 't@t'
    }
  }).toString()
}

// One commit each — enough for the repo to have a lastCommit and sort.
async function makeBareRepo (reposDir, name) {
  const barePath = join(reposDir, name + '.git')
  const workPath = join(reposDir, '..', 'work-' + name)
  await mkdir(barePath, { recursive: true })
  await mkdir(workPath, { recursive: true })
  git(barePath, ['init', '--bare', '-q'])
  git(workPath, ['init', '-q'])
  await writeFile(join(workPath, 'f'), name)
  git(workPath, ['add', 'f'])
  git(workPath, ['commit', '-q', '-m', 'init ' + name])
  const branch = git(workPath, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()
  git(workPath, ['remote', 'add', 'origin', barePath])
  git(workPath, ['push', '-q', 'origin', branch])
}

const REPO_COUNT = 32 // more than one page, less than two

test('/repos ships one page and /api/repos/page serves the rest', async (t) => {
  const root = tmp('pgv3-repospage-')
  const reposDir = join(root, 'git-repos')
  await mkdir(reposDir, { recursive: true })
  for (let i = 0; i < REPO_COUNT; i++) {
    await makeBareRepo(reposDir, 'r' + String(i).padStart(2, '0'))
  }

  const node = await startPearGit({
    storage: tmp('pgv3-repospage-s-'),
    uds: join(tmp('pgv3-repospage-u-'), 'svc.sock'),
    http: { port: 0 },
    swarm: false,
    deploy: false,
    sync: true,
    offline: true,
    reposDir,
    autoImportSSH: false
  })
  t.teardown(async () => { try { await node.close() } catch {} })

  const port = node.svc.http.server.address().port
  const base = 'http://127.0.0.1:' + port

  // ── the landing page ────────────────────────────────────────
  const page = (await httpGet(base + '/repos')).body

  const cardsOnFirstPage = page.split('class="repo-card"').length - 1
  t.is(cardsOnFirstPage, PAGE_SIZE, 'landing page renders exactly one page of cards')
  t.ok(cardsOnFirstPage < REPO_COUNT, 'and therefore NOT the whole fleet')

  t.ok(page.includes('id="repo-more"'), 'the view-more control is present')
  t.ok(page.includes('pgv3-repos.js'), 'the client script is linked')
  t.ok(page.includes('value="0">all remaining'), '"all remaining" is offered')

  // The client reads its starting offset off these attributes; a mismatch
  // would silently skip or repeat repos on the first "view more".
  const nextOffset = Number(/data-next-offset="(\d+)"/.exec(page)[1])
  const remaining = Number(/data-remaining="(\d+)"/.exec(page)[1])
  const total = Number(/data-total="(\d+)"/.exec(page)[1])
  t.is(nextOffset, PAGE_SIZE, 'paging resumes where the server stopped')
  t.is(total, REPO_COUNT, 'total counts every repo')
  t.is(remaining, REPO_COUNT - PAGE_SIZE, 'remaining is what is left below the fold')

  // ── the fragment route ──────────────────────────────────────
  const res = await getJson(base + '/api/repos/page?offset=' + nextOffset + '&limit=25')
  t.is(res.status, 200, 'fragment route answers')
  const body = res.body

  t.is(body.count, REPO_COUNT - PAGE_SIZE, 'serves the remainder')
  t.is(body.remaining, 0, 'and reports itself exhausted so the control hides')
  t.is(body.nextOffset, REPO_COUNT, 'offset advances to the end')
  t.is(body.html.split('class="repo-card"').length - 1, body.count, 'one card per repo returned')

  // ── the two halves must partition the fleet ─────────────────
  const namesIn = (html) => {
    const out = []
    const re = /\/repos\/(r\d\d)"/g
    let m
    while ((m = re.exec(html))) out.push(m[1])
    return out
  }
  const firstNames = namesIn(page)
  const restNames = namesIn(body.html)
  const all = firstNames.concat(restNames)

  t.is(all.length, REPO_COUNT, 'every repo appeared exactly once across both')
  t.is(new Set(all).size, REPO_COUNT, 'no repo served twice')

  // ── "all remaining" (limit=0) is unbounded ──────────────────
  const allBody = (await getJson(base + '/api/repos/page?offset=0&limit=0')).body
  t.is(allBody.count, REPO_COUNT, 'limit=0 returns the entire list')
  t.is(allBody.remaining, 0, 'nothing left after it')

  // ── a past-the-end page is empty, not an error ──────────────
  const pastRes = await getJson(base + '/api/repos/page?offset=9999&limit=25')
  t.is(pastRes.status, 200, 'past-the-end still answers 200')
  const pastBody = pastRes.body
  t.is(pastBody.count, 0, 'with no cards')
  t.is(pastBody.remaining, 0, 'and nothing remaining')

  // ── a hostile limit cannot ask for an unbounded render ──────
  const hugeBody = (await getJson(base + '/api/repos/page?offset=0&limit=999999')).body
  t.ok(hugeBody.count <= 200, 'an explicit limit is capped at 200')
})