Skip to content

Commit ad08450

Browse files
committed
test: cover the link preview widgets and the search providers
CI builds the frontend but never runs it, and the pages the suite visits so far never load the reference bundle, so a dependency update that breaks a link preview still passes with every check green. The new tests load that bundle the way Nextcloud does wherever a link preview is rendered, render each widget of the app from a fixed rich object, and check that the app offers its providers to the smart picker and to unified search and that a search answers an empty result for a user who has connected no account. The server container also gets ten minutes to start instead of five, after a run failed on a slow runner while the container was still booting. Signed-off-by: Oleksander Piskun <oleksandr2088@icloud.com>
1 parent 087112a commit ad08450

2 files changed

Lines changed: 169 additions & 1 deletion

File tree

‎playwright.config.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export default defineConfig({
4949
reuseExistingServer: !process.env.CI,
5050
stdout: 'pipe',
5151
stderr: 'pipe',
52-
timeout: 5 * 60 * 1000,
52+
// the shallow server image sometimes needs several minutes to boot on a slow runner
53+
timeout: 10 * 60 * 1000,
5354
},
5455
})
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { Page } from '@playwright/test'
7+
8+
import { login } from '@nextcloud/e2e-test-server/playwright'
9+
import { test as base, expect } from '@playwright/test'
10+
11+
// the test container always has this admin user
12+
const admin = { userId: 'admin', password: 'admin' }
13+
const ocs = { 'OCS-APIRequest': 'true', Accept: 'application/json' }
14+
15+
// served as the avatar of the mocked author
16+
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 'base64')
17+
18+
// an issue as the app puts it into the rich object of a link preview
19+
const issue = {
20+
github_type: 'issue',
21+
github_repo_owner: 'nextcloud',
22+
github_repo: 'integration_github',
23+
github_issue_id: '243',
24+
id: 243,
25+
number: 243,
26+
title: 'The dashboard widget stays empty',
27+
html_url: 'https://github.com/nextcloud/integration_github/issues/243',
28+
state: 'closed',
29+
state_reason: 'completed',
30+
comments: 2,
31+
created_at: '2026-09-18T10:00:00Z',
32+
updated_at: '2026-09-18T11:00:00Z',
33+
user: { login: 'janedoe' },
34+
labels: [{ name: 'bug', color: 'd73a4a' }],
35+
assignees: [],
36+
reactions: { total_count: 0 },
37+
}
38+
39+
// a code permalink as the app puts it into the rich object of a link preview
40+
const permalink = {
41+
github_type: 'code',
42+
owner: 'nextcloud',
43+
repo: 'integration_github',
44+
filePath: 'src/reference.js',
45+
link: 'https://github.com/nextcloud/integration_github/blob/5f3c2a1/src/reference.js#L10-L12',
46+
html_url: 'https://github.com/nextcloud/integration_github/blob/5f3c2a1/src/reference.js#L10-L12',
47+
ref: { original_ref: 'main', sha: '5f3c2a1b8d4e6f7a9c0b1d2e3f4a5b6c7d8e9f01' },
48+
lineBegin: 10,
49+
lineEnd: 12,
50+
lines: ['const first = 1', 'const second = 2', 'const third = 3'],
51+
}
52+
53+
// Every test also fails on an uncaught exception, or on an unexpected failing request to one of the app's own routes.
54+
// Errors of other apps on the instance are ignored on purpose.
55+
const test = base.extend<{ appErrors: void }>({
56+
appErrors: [async ({ page }, use) => {
57+
const errors: string[] = []
58+
page.on('pageerror', (error) => errors.push(`uncaught: ${error.message}`))
59+
page.on('response', (response) => {
60+
if (response.status() >= 400 && response.url().includes('/integration_github/')) {
61+
errors.push(`${response.status()} ${response.request().method()} ${new URL(response.url()).pathname}`)
62+
}
63+
})
64+
await use()
65+
expect(errors).toEqual([])
66+
}, { auto: true }],
67+
})
68+
69+
/**
70+
* Load the reference bundle of the app and render one of its widgets, the way Nextcloud does
71+
* wherever a link preview appears.
72+
*
73+
* @param page a page of a logged in user
74+
* @param type the rich object type of the widget
75+
* @param richObject the rich object to render
76+
*/
77+
async function renderReferenceWidget(page: Page, type: string, richObject: object) {
78+
// the widget asks the app for the avatar of a GitHub user, which needs an account
79+
await page.route('**/apps/integration_github/avatar**', (route) => route.fulfill({ contentType: 'image/png', body: png }))
80+
await page.goto('apps/files/')
81+
const registered = await page.evaluate(async ([widgetType, object]) => {
82+
const globals = window as unknown as {
83+
OC: { appswebroots: Record<string, string> }
84+
_vue_richtext_widgets: Record<string, { callback: (element: HTMLElement, data: object) => void }>
85+
}
86+
await import(/* @vite-ignore */ `${globals.OC.appswebroots.integration_github}/js/integration_github-reference.mjs`)
87+
const element = document.createElement('div')
88+
element.id = 'reference-widget'
89+
document.body.appendChild(element)
90+
globals._vue_richtext_widgets[widgetType as string].callback(element, {
91+
richObjectType: widgetType,
92+
richObject: object,
93+
accessible: false,
94+
})
95+
return Object.keys(globals._vue_richtext_widgets)
96+
}, [type, richObject] as [string, object])
97+
expect(registered).toEqual(expect.arrayContaining(['integration_github_issue_pr', 'integration_github_code_permalink']))
98+
return page.locator('#reference-widget')
99+
}
100+
101+
test.beforeEach(async ({ page }) => {
102+
await login(page.request, admin)
103+
})
104+
105+
test.describe('Link previews', () => {
106+
test('offer the providers to the smart picker', async ({ page }) => {
107+
const response = await page.request.get('../ocs/v2.php/references/providers', { headers: ocs })
108+
expect(response.ok()).toBe(true)
109+
const providers = (await response.json()).ocs.data as Array<{ id: string, title: string, icon_url: string }>
110+
const expected = {
111+
'github-issue-pr': 'GitHub issues, pull requests and comments',
112+
'github-permalink': 'GitHub code permalink',
113+
}
114+
for (const [id, title] of Object.entries(expected)) {
115+
const provider = providers.find((candidate) => candidate.id === id)
116+
expect(provider, `provider ${id}`).toBeDefined()
117+
expect(provider?.title).toBe(title)
118+
// the provider icon is served by the app
119+
expect((await page.request.get(provider!.icon_url)).ok()).toBe(true)
120+
}
121+
})
122+
123+
test('render an issue in the reference widget', async ({ page }) => {
124+
const widget = await renderReferenceWidget(page, 'integration_github_issue_pr', issue)
125+
126+
const link = widget.getByRole('link', { name: issue.title })
127+
await expect(link).toBeVisible()
128+
await expect(link).toHaveAttribute('href', issue.html_url)
129+
await expect(widget.getByText('nextcloud/integration_github #243')).toBeVisible()
130+
await expect(widget.getByText('by janedoe')).toBeVisible()
131+
await expect(widget.getByText('bug', { exact: true })).toBeVisible()
132+
})
133+
134+
test('render a code permalink in the reference widget', async ({ page }) => {
135+
const widget = await renderReferenceWidget(page, 'integration_github_code_permalink', permalink)
136+
137+
await expect(widget.getByText('nextcloud/integration_github/src/reference.js')).toBeVisible()
138+
await expect(widget.getByText('Line 10 to 12')).toBeVisible()
139+
await expect(widget.getByText('const second = 2')).toBeVisible()
140+
})
141+
})
142+
143+
test.describe('Search providers', () => {
144+
test('offer the providers to unified search', async ({ page }) => {
145+
const response = await page.request.get('../ocs/v2.php/search/providers', { headers: ocs })
146+
expect(response.ok()).toBe(true)
147+
const providers = (await response.json()).ocs.data as Array<{ id: string, appId: string, name: string }>
148+
const expected = {
149+
'github-search-issues': 'GitHub issues and pull requests',
150+
'github-search-repos': 'GitHub repositories',
151+
}
152+
for (const [id, name] of Object.entries(expected)) {
153+
expect(providers.find((candidate) => candidate.id === id), `provider ${id}`).toMatchObject({
154+
appId: 'integration_github',
155+
name,
156+
})
157+
}
158+
})
159+
160+
test('answer an empty result for a user without a GitHub account', async ({ page }) => {
161+
for (const id of ['github-search-issues', 'github-search-repos']) {
162+
const response = await page.request.get(`../ocs/v2.php/search/providers/${id}/search?term=nextcloud`, { headers: ocs })
163+
expect(response.ok(), id).toBe(true)
164+
expect((await response.json()).ocs.data.entries, id).toEqual([])
165+
}
166+
})
167+
})

0 commit comments

Comments
 (0)