// /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')
})