Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
26c46db
feat(screenshots): add Playwright screenshot automation infrastructure
May 19, 2026
955641b
feat(screenshots): add e2e specs for files, web interface, and Talk
May 19, 2026
54a9323
fix(screenshots): fix group-public-settings to show guest/moderation …
miaulalala May 19, 2026
835f89f
fix(screenshots): fix messages-expiration crop and seed note-to-self …
miaulalala May 19, 2026
a6af902
feat(screenshots): extend seed data for richer screenshot content
miaulalala May 19, 2026
f59a620
feat(screenshots): add richer seed data — docx, reactions, extra rooms
miaulalala May 19, 2026
7caf561
fix(screenshots): find group-public-settings by content not nav label
miaulalala May 19, 2026
f2ea380
fix(screenshots): scope group-public-settings nav click to dialog
miaulalala May 19, 2026
d701304
fix(screenshots): all 42 tests pass — group-public-settings and messa…
miaulalala May 19, 2026
bfc7b2f
feat(screenshots): richer seed data, 4 new users, production-like Tal…
miaulalala May 19, 2026
f647afa
fix(screenshots): fix DM seeding guards and note-to-self room init
miaulalala May 19, 2026
f6ed1dd
fix(screenshots): fix ban-participant-list and archived-conversations…
miaulalala May 19, 2026
c2c0cc3
feat(screenshots): seed file shares inline between chat messages
miaulalala May 19, 2026
0147655
chore: add screenshot composition guidelines to AGENTS.md
miaulalala May 20, 2026
6e7d87c
feat(screenshots): fix archived/new-room/creating-conversation screen…
miaulalala May 20, 2026
af6e714
chore: document pressSequentially pattern for Vue reactive search inputs
miaulalala May 20, 2026
784b3c0
feat(screenshots): fix Talk conversations spec — emoji API, seeding g…
miaulalala May 20, 2026
c6e99fd
feat(screenshots): add scheduled meeting to Talk dashboard and Calend…
miaulalala May 20, 2026
94f783f
refactor(screenshots): extract seed layer from spec into playwright/s…
miaulalala May 20, 2026
dc79fd4
feat(screenshots): seed file system and sharing state in global-setup
miaulalala May 20, 2026
722ae7f
feat(screenshots): expand file fixtures and seed richer file/sharing …
miaulalala May 20, 2026
9f58149
chore(screenshots): move fixtures from cypress/ to playwright/fixtures/
miaulalala May 20, 2026
da5a403
feat(screenshots): add full names, email addresses, and profile data …
miaulalala May 20, 2026
4fcaaa1
feat(screenshots): backdate seeded Talk messages and file mtimes
miaulalala May 20, 2026
77153ec
fix(screenshots): fix Talk timestamp backdating breaking the rooms API
miaulalala May 20, 2026
0f0888e
feat(screenshots): improve Files screenshots with realistic folder da…
miaulalala May 20, 2026
9cef1ef
feat(screenshots): delete welcome.txt from Christine's files
miaulalala May 20, 2026
102a441
docs(agents): add screenshot patterns for folder mtime backdating and…
miaulalala May 20, 2026
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
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,15 @@ Pipfile.lock

# ack-grep
.ackrc

# Screenshot automation
node_modules/
cypress/snapshots/
cypress/videos/
cypress/downloads/
screenshot-inventory.json
cypress/fixtures/pdfs/
playwright/results/
playwright/.auth/
playwright/fixtures/pdfs/
playwright/fixtures/images/
155 changes: 155 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,161 @@ gh issue edit NNNN \

Use `fixes #NNNN` in the PR body to auto-close on merge; use `relates to #NNNN` if the PR only partially addresses the issue.

## Screenshot composition

Rules for Playwright screenshot specs. Refine this section as new patterns emerge.

### Clip to element bounding box, not container offsets

Always anchor clips to the target element's own `boundingBox()`, never to hardcoded pixel offsets
from a parent container. Fixed offsets break silently when adjacent UI (badges, notification buttons,
extra rows) shifts position.

```typescript
const btn = page.locator('button', { hasText: 'Archived conversations' })
const listEl = page.locator('[aria-label="Conversation list"]')
const listBox = await listEl.boundingBox()
const btnBox = await btn.boundingBox()
if (listBox && btnBox) {
await page.screenshot({
path: dest,
clip: {
x: listBox.x,
y: btnBox.y - 80, // ~80px above to show context
width: listBox.width,
height: btnBox.height + 88, // button height + ~8px below
},
})
}
```

### Show menu/list items in context

When screenshotting a button or item inside a list or panel, include enough surrounding rows to show
where it lives. ~80px above the target is a reasonable default; adjust if nearby rows are unusually
tall. A crop so tight the element appears orphaned gives users no spatial reference.

### Wait for animation before screenshotting nested panels

If clicking a button navigates *within* a container (not a separate modal), the replaced section may
still be animating out. Wait for it to reach `state: 'hidden'`, then add a short `waitForTimeout(400)`
to let the incoming panel settle:

```typescript
await page.locator('button:has-text("Manage bans")').click()
const banDialog = page.getByRole('dialog', { name: /banned users/i })
await banDialog.waitFor({ state: 'visible', timeout: 10000 })
await page.locator('#settings-section_conversation-settings')
.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
await page.waitForTimeout(400)
await banDialog.screenshot({ path: dest })
```

### Use `pressSequentially` for Vue reactive search inputs

`fill()` sets an input's value in one shot and can bypass Vue's reactive watchers, leaving the UI
in its previous state (e.g. a search field appears empty, results never update). Use
`pressSequentially` with a small inter-key delay so each keystroke fires its own input event:

```typescript
// fill() bypasses Vue reactivity — use pressSequentially instead
await searchInput.click()
await searchInput.pressSequentially('laptop', { delay: 80 })
// Confirm reactivity fired: wait for a DOM change only possible after the search updates
await page.locator('.frequently-used-label').waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {})
await page.locator('.search-result').first().click({ timeout: 3000 }).catch(() => {})
```

### Guard conditional `boundingBox()` calls with `isVisible()`

`locator.boundingBox()` waits for the element using the full action timeout (default 30–60 s) when the
element is absent from the DOM. An unguarded `.catch(() => null)` does not help — the 60 s timeout fires
before the catch runs. Always check `isVisible()` first (instant, no wait) before calling `boundingBox()`:

```typescript
// Wrong — times out for 60s if element is absent:
const box = await locator.boundingBox().catch(() => null)

// Correct — instant check, then fetch box only when present:
const box = (await locator.isVisible()) ? await locator.boundingBox() : null
```

This is especially important inside screenshot tests that compute clip regions from optional UI
(e.g. a badge button that only appears under certain conditions).

### Seed rich content inline between messages

File shares, reactions, and other message cards must be seeded *between* the surrounding messages
they should appear near. Seeding them before the conversation is populated places them at the top of
chat history, scrolled out of the visible viewport by the time the screenshot is taken.

```typescript
await seedChatMessages(token, [ /* messages before the share */ ])
await uploadFile(path, name, user, password)
await ocsRequest('POST', '/ocs/v2.php/apps/files_sharing/api/v1/shares', user, password, {
shareType: '10', path: `/${name}`, shareWith: token,
})
await seedChatMessages(token, [ /* messages after the share */ ])
```

### Backdating folder mtimes in the Files app

NC's `oc_filecache.mtime` is what the Files app displays, but you must update **both** the DB and
the real filesystem — NC's lazy scanner re-reads the filesystem on every PROPFIND and will overwrite
a DB-only change. Use `files/FolderName` as the path (the home storage prepends `files/`):

```typescript
// Update oc_filecache
const sql = `UPDATE oc_filecache SET mtime=${ts}, storage_mtime=${ts}
WHERE path='files/Documents'
AND storage=(SELECT numeric_id FROM oc_storages WHERE id='home::christine')`
// Also touch the real directory
await runExec(['touch', '-m', '-d', `@${ts}`, '/var/www/html/data/christine/files/Documents'])
```

Run backdates **after** the first login — apps like Talk initialise their storage folder on first
login and would overwrite an earlier backdate.

NC's in-process Sabre/DAV cache (per Apache worker, not APCu) holds the mtime from first access
and ignores external writes for the lifetime of that worker. For screenshots that must show a
specific folder date, intercept the PROPFIND response and patch `getlastmodified` directly:

```typescript
// In beforeEach — intercepts the root directory listing for all tests
await page.route('**/remote.php/dav/files/christine/', async (route, request) => {
if (request.method() !== 'PROPFIND') { await route.continue(); return }
try {
const response = await route.fetch()
const body = await response.text()
const patched = body.replace(
/(<d:href>[^<]*\/Talk\/<\/d:href>[\s\S]*?<d:getlastmodified>)(.*?)(<\/d:getlastmodified>)/,
'$1Thu, 15 May 2026 00:00:00 GMT$3',
)
await route.fulfill({ response, body: patched, contentType: response.headers()['content-type'] || 'application/xml' })
} catch (_) {
await route.continue().catch(() => {})
}
})
```

### `oc_talk_rooms.last_activity` is a DATETIME column, not a Unix integer

Writing a raw integer to this column causes PHP's `new DateTime()` to throw (HTTP 500). Use
SQLite's `datetime()` conversion:

```sql
UPDATE oc_talk_rooms SET last_activity = datetime(1748822400, 'unixepoch') WHERE token = 'abc'
```

Talk's `getRooms` API also filters rooms by `modifiedSince`. Backdating `last_activity` makes rooms
disappear after the first poll. Fix by setting `last_attendee_activity` 2 h in the future — Talk
passes the room if either timestamp is recent enough:

```sql
UPDATE oc_talk_attendees SET last_attendee_activity = <now + 7200>
WHERE room_id = (SELECT id FROM oc_talk_rooms WHERE token = 'abc') AND actor_id = 'christine'
```

## CI checks (must all pass)

| Check | What it catches |
Expand Down
Loading
Loading