Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 38 additions & 21 deletions tests/e2e/regression/manifest-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ type ManifestPage = {
}

/**
* All 35 manifest pages. Kept in manifest order so a diff against
* All 37 manifest pages. Kept in manifest order so a diff against
* `src/manifest.json` reads straight down.
*
* Guarded by `manifest page table is complete and current` — do not edit this
Expand Down Expand Up @@ -221,6 +221,7 @@ const MANIFEST_PAGES: ManifestPage[] = [
type: 'custom',
component: 'ApprovalDetail',
},
{ id: 'Reports', route: '/reports', type: 'reports' },
{ id: 'Traces', route: '/traces', type: 'logs' },
{
id: 'TraceDetail',
Expand Down Expand Up @@ -367,6 +368,35 @@ test.describe('manifest schema validation', () => {
// This suite compiles as CommonJS, so `import.meta` is a syntax error and
// `require` is how it reaches the filesystem. The directives below say so
// at each site; the reason is here.
/**
* Every page `type` the manifest schema accepts.
*
* Read from the vendored schema rather than restated here, because a
* restatement is what went stale. Throws rather than falling back to a
* default set: a test that cannot find the schema must say so, not quietly
* accept every type it is shown.
*
* @return The page-type enum.
*/
function readPageTypes(): string[] {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const schemaPath = require('path').resolve(
__dirname,
'../../../node_modules/@conduction/nextcloud-vue/src/schemas/app-manifest-v2.schema.json',
)
// eslint-disable-next-line @typescript-eslint/no-require-imports
const schema = JSON.parse(require('fs').readFileSync(schemaPath, 'utf-8'))
const types = schema?.$defs?.page?.properties?.type?.enum

if (!Array.isArray(types) || types.length === 0) {
throw new Error(
`no page-type enum at $defs.page.properties.type.enum in ${schemaPath}`,
)
}

return types as string[]
}

function readManifest(): Record<string, any> {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const manifestPath = require('path').resolve(
Expand Down Expand Up @@ -488,26 +518,13 @@ test.describe('manifest schema validation', () => {

test('every page uses a standard type or has a _note justifying custom', async () => {
const m = readManifest()
// Standard nc-vue page types (ADR-030). `roadmap` is a recognised
// extension type used by FeaturesRoadmap.
const STANDARD = new Set([
'index',
'detail',
'dashboard',
'logs',
'settings',
'chat',
'files',
'form',
'wiki',
'map',
'roadmap',
// The flow EDITOR. A flow lives in OpenRegister's native flow table
// rather than a register/schema pair, so one flow is not expressible
// as `detail` — but the LIST is an ordinary `index` over a named
// source, which is why there is no matching `flows` type here.
'flow',
])

// 🔴 THE SCHEMA IS THE LIST, NOT A COPY OF IT. This was twelve
// hand-written strings, and it fell behind: `reports` is a page type the
// manifest schema has accepted for a while, the manifest started using
// it, and this test called it unknown. A copy of an enum drifts from the
// enum; reading the enum cannot.
const STANDARD = new Set<string>(readPageTypes())
for (const p of m.pages) {
if (p.type === 'custom') {
expect(
Expand Down
41 changes: 39 additions & 2 deletions tests/e2e/spec-coverage/demo-data-setup-step.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ async function api(
page: Page,
method: string,
apiPath: string,
body?: unknown,
): Promise<{ status: number; json: any }> {
return await page.evaluate(
async ({ method, apiPath }) => {
async ({ method, apiPath, body }) => {
const res = await fetch(apiPath, {
method,
headers: {
Expand All @@ -59,6 +60,7 @@ async function api(
requesttoken: (window as any).OC?.requestToken || '',
'OCS-APIREQUEST': 'true',
},
body: body === undefined ? undefined : JSON.stringify(body),
})
let json: any
try {
Expand All @@ -68,10 +70,41 @@ async function api(
}
return { status: res.status, json }
},
{ method, apiPath },
{ method, apiPath, body },
)
}

/**
* Choose the shipped dataset, and answer with the id that was chosen.
*
* 🔴 THE TEST HAS TO MAKE THE DECISION IT ASSERTS AGAINST. The demo-data step
* is a choice followed by a load step now, and the CI seed settles the optional
* steps by posting `skip-demo-data` — which records "none". A load that follows
* correctly imports nothing, so an install test that skips this arranges no
* precondition and measures the seed instead of the app.
*
* The id comes from `/api/setup/status` rather than a literal: the choice step
* reads its options from exactly that list, so a hardcoded id can pass while
* the list an operator sees is empty.
*/
async function pickShippedDataset(page: Page): Promise<string> {
const status = await api(page, 'GET', `${BASE}/api/setup/status`)
const shipped = (status.json?.datasets ?? []).find(
(d: any) => d?.id && d.id !== 'none',
)
expect(
shipped,
`setup/status offers no dataset to load: ${JSON.stringify(status.json?.datasets)}`,
).toBeTruthy()

const saved = await api(page, 'POST', `${BASE}/api/setup/config`, {
demo_dataset: shipped.id,
})
expect(saved.status, JSON.stringify(saved.json)).toBe(200)

return shipped.id
}

test.describe.configure({ mode: 'serial' })

test.describe('ADR-111 demo data', () => {
Expand Down Expand Up @@ -116,6 +149,8 @@ test.describe('ADR-111 demo data', () => {
// WROTE something.
test.slow()

await pickShippedDataset(page)

const res = await api(
page,
'POST',
Expand Down Expand Up @@ -148,6 +183,8 @@ test.describe('ADR-111 demo data', () => {
// The step body tells the operator it is "safe to run more than once".
// That sentence is a contract; this asserts the server keeps it rather
// than erroring or reporting failure on a second pass.
await pickShippedDataset(page)

const again = await api(
page,
'POST',
Expand Down
25 changes: 19 additions & 6 deletions tests/e2e/spec-coverage/features-roadmap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@ test.describe('Features & roadmap — index surface', () => {
).toBeVisible({ timeout: 15_000 })

// Primary actions surfaced by the roadmap page.
// A LINK, not a button. nextcloud-vue 2.36.4 removed the in-product
// suggestion modal (team decision 2026-09-04: the forge is where the
// conversation happens), and the CTA is an anchor to the forge's
// feature-request issue form now. An `<a href>` has role `link`.
const action = page
.getByRole('button', { name: /Show roadmap|Suggest (a )?feature/i })
.getByRole('button', { name: /Show roadmap/i })
.or(page.getByRole('link', { name: /Suggest (a )?feature/i }))
.first()
await expect(
action,
Expand All @@ -39,14 +44,22 @@ test.describe('Features & roadmap — index surface', () => {
await page.goto(`${APP_BASE}/features-roadmap`, {
waitUntil: 'domcontentloaded',
})
// A LINK, not a button. nextcloud-vue 2.36.4 removed the in-product
// suggestion modal (team decision 2026-09-04: the forge is where the
// conversation happens), and the CTA is an anchor to the forge's
// feature-request issue form now. An `<a href>` has role `link`.
const suggest = page
.getByRole('button', { name: /Suggest (a )?feature/i })
.getByRole('link', { name: /Suggest (a )?feature/i })
.first()
await expect(suggest).toBeVisible({ timeout: 15_000 })
await suggest.click()
// Either opens a dialog or navigates to an external suggestion target;
// assert the click did not produce an app error.
await page.waitForTimeout(1_000)

// 🔴 READ THE TARGET, DO NOT FOLLOW IT. The CTA now leaves the app for a
// real issue form on a real forge, so clicking it in CI would navigate
// off the instance under test and out to the network.
const href = await suggest.getAttribute('href')
expect(href, 'the CTA rendered without a target').toBeTruthy()
expect(href, `the CTA points at ${href}`).toMatch(/issues\/new/)
await expect(suggest).toHaveAttribute('target', '_blank')
assertNoAppErrors(sink)
})
})
Loading