// Actual disposable Nextcloud pagination; never an installed-provider probe. const assert = require('node:assert/strict') const fs = require('node:fs/promises') const { randomUUID } = require('node:crypto') const COUNT = 251 // Production Nextcloud PAGE_ITEMS is 250; do not shrink it for tests. const nameOf = index => `page-${String(index).padStart(4, '0')}.txt` const bytesOf = name => `Owned pagination fixture: ${name}\n` const pathOf = folder => `/remote.php/dav/files/alice/${encodeURIComponent(folder)}/` async function createNew(api, method, path, data, timeout = 5000, onFailure = () => {}) { const response = await api.fetch(path, { method, data, timeout, maxRedirects: 0, maxRetries: 0, headers: { 'Content-Type': 'text/plain', 'If-None-Match': '*' } }) try { assert.equal(response.status(), 201, `${method} must create owned data, never overwrite`) } catch (error) { onFailure(error); throw error } finally { await response.dispose() } } // Exported for fake-transport control tests only. Real entry is guarded below. async function seedPagination(api, folder) { assert.match(folder, /^archy-pagination-[a-f0-9-]{36}$/) const deadline = Date.now() + 30000 const remaining = () => { const value = deadline - Date.now() assert.ok(value > 0, 'Pagination seed deadline exceeded') return Math.min(value, 5000) } await createNew(api, 'MKCOL', pathOf(folder), '', remaining()) let next = 0, failure = null await Promise.all(Array.from({ length: 4 }, async () => { while (!failure && next < COUNT) { const name = nameOf(next++) try { await createNew(api, 'PUT', pathOf(folder) + name, bytesOf(name), remaining(), error => { failure ??= error }) } catch (error) { failure ??= error } } })) if (failure) throw failure return Array.from({ length: COUNT }, (_, index) => nameOf(index)) } async function exerciseNextcloudPagination({ page, request, expect, connectionId, rpc }) { assert.equal(process.env.ARCHY_TEST_ISOLATED, '1') assert.equal(process.env.ARCHY_TEST_CLOUD_UPSTREAM, 'nextcloud29') const canary = process.env.ARCHY_TEST_HOST_CANARY || '' assert.match(canary, /^\/tmp\/archy-cloud-test\.[^/]+\/host-only-canary$/) await assert.rejects(fs.access(canary), { code: 'ENOENT' }) // Separate request context: never forward the backend's session cookies. const api = await request.newContext({ baseURL: 'http://127.0.0.1', timeout: 5000, extraHTTPHeaders: { Authorization: 'Basic ' + Buffer.from('alice:Synthetic-Cloud-A-Only-2026!').toString('base64') } }) const folder = `archy-pagination-${randomUUID()}` const section = page.getByRole('region', { name: 'Connected libraries', exact: true }) const downloads = section.getByRole('button', { name: /^Download page-\d{4}\.txt$/ }) const names = () => downloads.evaluateAll(nodes => nodes.map(node => node.getAttribute('aria-label').slice('Download '.length))) const responseFor = number => page.waitForResponse(response => { if (new URL(response.url()).pathname !== '/rpc/v1' || response.request().method() !== 'POST') return false const body = response.request().postDataJSON() return body?.method === 'cloud.sources.browse' && body.params?.connection_id === connectionId && body.params.page === number && JSON.stringify(body.params.folder) === JSON.stringify([folder]) }) const resultOf = async response => { assert.equal(response.status(), 200) const body = await response.json() assert.ok(!body.error && body.result, 'Actual browse must succeed') return body.result } try { const expected = await seedPagination(api, folder) await section.getByRole('button', { name: 'Refresh', exact: true }).click() await expect(section.getByRole('button', { name: folder, exact: true })).toBeVisible() const firstResponse = responseFor(1) await section.getByRole('button', { name: folder, exact: true }).click() const first = await resultOf(await firstResponse) assert.equal(first.items.length, 250) assert.equal(first.next_page, 2) assert.match(first.listing_revision, /^[a-f0-9]{64}$/) await expect(downloads).toHaveCount(250) assert.deepEqual((await names()).sort(), first.items.map(item => item.name).sort()) const secondResponse = responseFor(2) await section.getByRole('button', { name: 'Next', exact: true }).click() const wire = await secondResponse assert.equal(wire.request().postDataJSON().params.listing_revision, first.listing_revision) const second = await resultOf(wire) assert.equal(second.items.length, 1) assert.equal(second.next_page, null) assert.equal(second.listing_revision, first.listing_revision) const all = [...first.items, ...second.items] assert.equal(new Set(all.map(item => item.upstream_id)).size, COUNT) assert.deepEqual(all.map(item => item.name).sort(), expected.sort()) await expect(downloads).toHaveCount(1) await expect(section.getByText('Page 2', { exact: true })).toBeVisible() await expect(section.getByRole('button', { name: 'Next', exact: true })).toBeDisabled() // Upstream identity ordering is NOT filename ordering. Use the actual tail. const tail = second.items[0].name const pending = page.waitForEvent('download') await section.getByRole('button', { name: `Download ${tail}`, exact: true }).click() const download = await pending assert.equal(download.suggestedFilename(), tail) assert.equal(await fs.readFile(await download.path(), 'utf8'), bytesOf(tail)) const back = responseFor(1) await section.getByRole('button', { name: 'Previous', exact: true }).click() assert.equal((await resultOf(await back)).listing_revision, first.listing_revision) await expect(downloads).toHaveCount(250) await createNew(api, 'PUT', pathOf(folder) + 'changed.txt', 'Owned listing mutation\n') const stale = responseFor(2) await section.getByRole('button', { name: 'Next', exact: true }).click() const staleResponse = await stale assert.equal(staleResponse.request().postDataJSON().params.listing_revision, first.listing_revision) assert.equal(staleResponse.status(), 200) const denied = await staleResponse.json() assert.ok(denied.error && !denied.result, 'A changed listing must not return a partial page') assert.equal(denied.error.code, -1) assert.equal(denied.error.message, 'Cloud source is unavailable') await expect(section.getByRole('alert')).toContainText('Cloud source is unavailable') await expect(downloads).toHaveCount(0) const refreshed = responseFor(1) await section.getByRole('button', { name: 'Refresh from first page', exact: true }).click() const fresh = await resultOf(await refreshed) assert.notEqual(fresh.listing_revision, first.listing_revision) assert.equal(fresh.items.length, 250) assert.equal(fresh.next_page, 2) const freshNames = fresh.items.map(item => item.name).filter(name => /^page-\d{4}\.txt$/.test(name)) await expect(downloads).toHaveCount(freshNames.length) assert.deepEqual((await names()).sort(), freshNames.sort()) // Production deliberately exposes only the outer error context. Prove the // revision cause with old/fresh/old requests, not an arbitrary generic error. const params = { connection_id: connectionId, folder: [folder], page: 2 } const rejectOld = () => assert.rejects(rpc('cloud.sources.browse', { ...params, listing_revision: first.listing_revision }), { message: 'Cloud source is unavailable' }) await rejectOld() const currentTail = await rpc('cloud.sources.browse', { ...params, listing_revision: fresh.listing_revision }) assert.equal(currentTail.listing_revision, fresh.listing_revision) assert.equal(currentTail.items.length, 2) assert.equal(currentTail.next_page, null) const changedItems = [...fresh.items, ...currentTail.items] assert.equal(new Set(changedItems.map(item => item.upstream_id)).size, COUNT + 1) assert.deepEqual(changedItems.map(item => item.name).sort(), [...expected, 'changed.txt'].sort()) await rejectOld() await section.getByRole('button', { name: 'All files', exact: true }).click() await expect(section.getByRole('button', { name: 'Download contract.txt', exact: true })).toBeVisible() console.log('PASS actual Nextcloud pagination: 250+1 unique items, later-page exact bytes, stable Previous, stale revision refusal and first-page refresh') } finally { await api.dispose() } } module.exports = { seedPagination, exerciseNextcloudPagination, COUNT }