Skip to content

Commit b213ae2

Browse files
committed
feat(screenshots): add Calendar, Contacts, Activity, Security, and Files extension specs
- calendar.spec.ts: 39 tests covering the full Calendar app — create/share/edit calendars, event CRUD, attendees, free/busy, reminders, repeat, attachments, trash, invitation response, CalDAV availability, appointment slots (all 6 config sections + public booking flow), and meeting proposals - contacts.spec.ts: 11 tests covering Contacts — list, new contact form, view mode, picture management, multi-select, address books, circles, shared items; uses search-based navigation to avoid virtual-scroll visibility issues - activity.spec.ts: 5 tests — activity stream (all/by-you/file-changes), per-file sidebar tab, and notification settings page - security.spec.ts: 5 tests — sessions list, app passwords, add app password, TOTP enable, backup code generation - files_extended.spec.ts: 12 tests — version history/actions/naming sidebar, public folder share options, others-with-access panels, file drop sharing panel + hide-listing option + anonymous upload, and transfer ownership accept - seed/calendar.ts: Work calendar, 5 Personal events (future with attendees, all-day, recurring, past, budget review), Work event, invitation from amara_w - seed/contacts.ts: 8 external contacts in Christine's default address book, Work Contacts address book with 3 contacts - seed/files.ts: File Drop folder with upload-only public share, pending transfer ownership from amara_w to christine - seed/index.ts + global-setup.ts: wire in calendar/contacts seeds and enable the circles + contacts apps All 115 tests pass. AI-Assisted-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Anna Larch <anna@nextcloud.com>
1 parent d94576c commit b213ae2

12 files changed

Lines changed: 1849 additions & 3 deletions
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
// SPDX-License-Identifier: AGPL-3.0-or-later
3+
4+
import { test } from '@playwright/test'
5+
import { docScreenshot, docElementScreenshot } from '../../helpers'
6+
7+
test('Activity stream — all activities', async ({ page }) => {
8+
await page.goto('/apps/activity')
9+
await page.locator('.activity-entry, [data-id], .virtualScrolling').first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {})
10+
await page.waitForTimeout(600)
11+
await docScreenshot(page, 'user/activity-stream-all')
12+
})
13+
14+
test('Activity stream — by you', async ({ page }) => {
15+
await page.goto('/apps/activity')
16+
await page.locator('.activity-entry, [data-id]').first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {})
17+
await page.locator('[aria-label="Activity navigation"] a, .app-navigation a').filter({ hasText: /by you/i }).click()
18+
await page.waitForTimeout(600)
19+
await docScreenshot(page, 'user/activity-stream-self')
20+
})
21+
22+
test('Activity stream — file changes', async ({ page }) => {
23+
await page.goto('/apps/activity')
24+
await page.locator('.activity-entry, [data-id]').first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {})
25+
await page.locator('[aria-label="Activity navigation"] a, .app-navigation a').filter({ hasText: /file change/i }).click()
26+
await page.waitForTimeout(600)
27+
await docScreenshot(page, 'user/activity-stream-file-changes')
28+
})
29+
30+
test('Activity sidebar in Files', async ({ page }) => {
31+
await page.goto('/apps/files')
32+
await page.locator('[data-cy-files-list]').waitFor({ state: 'visible', timeout: 15000 })
33+
// Open sidebar via Actions > Details on a specific file
34+
await page.locator('[data-cy-files-list-row][data-cy-files-list-row-name="Q2 Project Proposal.pdf"]')
35+
.locator('button[aria-label="Actions"]').click({ force: true })
36+
await page.locator('[data-cy-files-list-row-action="details"]').first().click()
37+
await page.locator('[data-cy-sidebar]').waitFor({ state: 'visible', timeout: 10000 })
38+
// Click the Activity tab in the sidebar
39+
await page.locator('[data-cy-sidebar] [role="tab"]').filter({ hasText: /activity/i }).click()
40+
await page.waitForTimeout(800)
41+
await docElementScreenshot(page, '[data-cy-sidebar]', 'user/activity-sidebar')
42+
})
43+
44+
test('Activity notification settings', async ({ page }) => {
45+
await page.goto('/settings/user/notifications')
46+
await page.locator('#app-content, .section').first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {})
47+
await page.waitForTimeout(600)
48+
await docScreenshot(page, 'user/activity-settings-personal')
49+
})

playwright/e2e/user/calendar.spec.ts

Lines changed: 700 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
// SPDX-License-Identifier: AGPL-3.0-or-later
3+
4+
import * as os from 'os'
5+
import * as path from 'path'
6+
import { test, Page } from '@playwright/test'
7+
import { docScreenshot } from '../../helpers'
8+
9+
const DEST = path.join(os.homedir(), 'Pictures', 'Screenshots', 'nextcloud-docs', 'user')
10+
const dest = (name: string) => path.join(DEST, name + '.png')
11+
12+
async function openContacts(page: Page): Promise<void> {
13+
await page.goto('/apps/contacts')
14+
await page.locator('.contacts-list, [class*="contact"], .app-content').first().waitFor({ state: 'visible', timeout: 20000 })
15+
await page.waitForTimeout(600)
16+
}
17+
18+
test('Contacts — main view (contacts list)', async ({ page }) => {
19+
await openContacts(page)
20+
await docScreenshot(page, 'user/contacts_empty')
21+
})
22+
23+
test('Contacts — bottom bar with New Contact button', async ({ page }) => {
24+
await openContacts(page)
25+
// The bottom bar / action bar with New Contact button
26+
const bottomBar = page.locator('.contacts-navigation-new-button, nav .actions, [class*="contact-header"]').first()
27+
await bottomBar.waitFor({ state: 'visible', timeout: 8000 }).catch(() => {})
28+
// Fallback: crop to the footer/bottom area of the sidebar
29+
const sidebar = page.locator('#app-navigation, nav').first()
30+
const sidebarBox = await sidebar.boundingBox()
31+
if (sidebarBox) {
32+
await page.screenshot({
33+
path: dest('contact_bottombar'),
34+
clip: {
35+
x: sidebarBox.x,
36+
y: sidebarBox.y + sidebarBox.height - 80,
37+
width: sidebarBox.width,
38+
height: 80,
39+
},
40+
})
41+
} else {
42+
await docScreenshot(page, 'user/contact_bottombar')
43+
}
44+
})
45+
46+
test('Contacts — import/upload button', async ({ page }) => {
47+
await openContacts(page)
48+
// The import button is usually in the sidebar header or a "..." action menu
49+
const importBtn = page.locator('button[aria-label*="import"], button[aria-label*="Import"], a[aria-label*="import"]').first()
50+
if (!(await importBtn.isVisible())) {
51+
// Try opening an action menu
52+
const actionsBtn = page.locator('button[aria-label*="Actions"], button[aria-label*="more"]').first()
53+
if (await actionsBtn.isVisible()) await actionsBtn.click()
54+
}
55+
await page.waitForTimeout(400)
56+
await docScreenshot(page, 'user/contact_uploadbutton')
57+
})
58+
59+
test('Contacts — new contact form', async ({ page }) => {
60+
await openContacts(page)
61+
const newContactBtn = page.locator('button', { hasText: /new contact/i }).first()
62+
await newContactBtn.waitFor({ state: 'visible', timeout: 10000 })
63+
await newContactBtn.click()
64+
await page.locator('[class*="contact-detail"], .contact-details, .app-content-details').waitFor({ state: 'visible', timeout: 10000 }).catch(() => {})
65+
await page.waitForTimeout(500)
66+
await docScreenshot(page, 'user/contact_new')
67+
})
68+
69+
/** Search for a contact by name so search results render visibly (bypasses virtual scroll). */
70+
async function searchContact(page: Page, name: string): Promise<void> {
71+
const searchInput = page.locator('input[type="search"], input[placeholder*="earch" i]').first()
72+
if (await searchInput.isVisible({ timeout: 3000 }).catch(() => false)) {
73+
await searchInput.fill(name)
74+
await page.waitForTimeout(600)
75+
}
76+
}
77+
78+
test('Contacts — contact view mode', async ({ page }) => {
79+
await openContacts(page)
80+
await searchContact(page, 'James')
81+
// After search, results are rendered visibly
82+
const result = page.locator('.list-item__wrapper:visible, .contact-entry:visible').first()
83+
if (await result.isVisible({ timeout: 5000 }).catch(() => false)) {
84+
await result.click()
85+
await page.waitForTimeout(500)
86+
}
87+
await docScreenshot(page, 'user/contact_view_mode')
88+
})
89+
90+
test('Contacts — contact picture in view', async ({ page }) => {
91+
await openContacts(page)
92+
await searchContact(page, 'Sofia')
93+
const result = page.locator('.list-item__wrapper:visible, .contact-entry:visible').first()
94+
if (await result.isVisible({ timeout: 5000 }).catch(() => false)) {
95+
await result.click()
96+
await page.waitForTimeout(500)
97+
}
98+
// Crop to the contact header / avatar area
99+
const avatar = page.locator('[class*="contact-avatar"], .avatar, .contact-header').first()
100+
if (await avatar.isVisible({ timeout: 5000 }).catch(() => false)) {
101+
const box = await avatar.boundingBox()
102+
if (box) {
103+
await page.screenshot({
104+
path: dest('contact_picture'),
105+
clip: { x: box.x - 16, y: box.y - 16, width: box.width + 32, height: box.height + 32 },
106+
})
107+
} else { await docScreenshot(page, 'user/contact_picture') }
108+
} else { await docScreenshot(page, 'user/contact_picture') }
109+
})
110+
111+
test('Contacts — contact picture options', async ({ page }) => {
112+
await openContacts(page)
113+
await searchContact(page, 'Ben')
114+
const result = page.locator('.list-item__wrapper:visible, .contact-entry:visible').first()
115+
if (await result.isVisible({ timeout: 5000 }).catch(() => false)) {
116+
await result.click()
117+
await page.waitForTimeout(500)
118+
}
119+
// Click on the avatar to open picture options
120+
const avatar = page.locator('[class*="contact-avatar"], .avatar, .avatar-button').first()
121+
if (await avatar.isVisible({ timeout: 3000 }).catch(() => false)) await avatar.click()
122+
const pictureMenu = page.locator('[role="menu"], .action-menu, .popover').filter({ hasText: /upload|photo|camera/i }).first()
123+
await pictureMenu.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {})
124+
if (await pictureMenu.isVisible()) {
125+
await pictureMenu.screenshot({ path: dest('contact_picture_options') })
126+
await page.keyboard.press('Escape')
127+
await page.waitForTimeout(300)
128+
await docScreenshot(page, 'user/contact_picture_set')
129+
} else {
130+
await docScreenshot(page, 'user/contact_picture_options')
131+
await docScreenshot(page, 'user/contact_picture_set')
132+
}
133+
})
134+
135+
test('Contacts — multi-select contacts', async ({ page }) => {
136+
await openContacts(page)
137+
// NC Contacts 8.x: select all via the "Select all" header checkbox if available,
138+
// then deselect down to 3 — or use the top header checkbox approach
139+
const selectAllCb = page.locator('input[type="checkbox"][aria-label*="all" i], .contact-list__select-all input').first()
140+
if (await selectAllCb.isVisible({ timeout: 3000 }).catch(() => false)) {
141+
await selectAllCb.click()
142+
await page.waitForTimeout(400)
143+
await docScreenshot(page, 'user/contact_multiselect')
144+
return
145+
}
146+
// Fallback: search for contacts to get visible items, then click their checkboxes
147+
await searchContact(page, 'a') // broad search to get multiple results
148+
const items = page.locator('.list-item__wrapper:visible')
149+
const count = await items.count()
150+
for (let i = 0; i < Math.min(3, count); i++) {
151+
const item = items.nth(i)
152+
await item.hover()
153+
const checkbox = item.locator('input[type="checkbox"]').first()
154+
if (await checkbox.isVisible({ timeout: 1000 }).catch(() => false)) {
155+
await checkbox.click()
156+
}
157+
await page.waitForTimeout(150)
158+
}
159+
await page.waitForTimeout(400)
160+
await docScreenshot(page, 'user/contact_multiselect')
161+
})
162+
163+
test('Contacts — manage address books dialog', async ({ page }) => {
164+
await openContacts(page)
165+
// Open the address books management dialog
166+
// Usually available via a gear/settings button in the sidebar
167+
const settingsBtn = page.locator('button[aria-label*="Settings"], button[aria-label*="manage"], button[title*="address book"]').first()
168+
if (await settingsBtn.isVisible()) {
169+
await settingsBtn.click()
170+
} else {
171+
// Try the app navigation action button
172+
const actionsBtn = page.locator('#app-navigation button[aria-label*="more"], #app-navigation button[aria-label*="action"]').first()
173+
if (await actionsBtn.isVisible()) await actionsBtn.click()
174+
}
175+
await page.waitForTimeout(400)
176+
const dialog = page.locator('[role="dialog"], .modal, [class*="addressbook"]').filter({ hasText: /address book/i }).first()
177+
await dialog.waitFor({ state: 'visible', timeout: 8000 }).catch(() => {})
178+
if (await dialog.isVisible()) {
179+
await dialog.screenshot({ path: dest('contact_manageaddressbook') })
180+
} else {
181+
await docScreenshot(page, 'user/contact_manageaddressbook')
182+
}
183+
})
184+
185+
test('Contacts — circles/teams view', async ({ page }) => {
186+
await openContacts(page)
187+
// Navigate to the Circles/Teams section in the left sidebar
188+
const circlesLink = page.locator('a, button', { hasText: /circle|team/i }).first()
189+
if (await circlesLink.isVisible()) await circlesLink.click()
190+
await page.waitForTimeout(600)
191+
await docScreenshot(page, 'user/circle')
192+
})
193+
194+
test('Contacts — shared items view', async ({ page }) => {
195+
await openContacts(page)
196+
// Navigate to "Shared with me" or "Shared items" in the sidebar
197+
const sharedLink = page.locator('a, button', { hasText: /shared/i }).first()
198+
if (await sharedLink.isVisible()) await sharedLink.click()
199+
await page.waitForTimeout(600)
200+
await docScreenshot(page, 'user/shared-items')
201+
})

0 commit comments

Comments
 (0)