|
| 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