een-api-toolkit 0.3.47 → 0.3.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.claude/agents/een-jobs-agent.md +676 -0
  2. package/CHANGELOG.md +7 -8
  3. package/dist/index.cjs +3 -3
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.ts +1172 -28
  6. package/dist/index.js +796 -333
  7. package/dist/index.js.map +1 -1
  8. package/docs/AI-CONTEXT.md +22 -1
  9. package/docs/ai-reference/AI-AUTH.md +1 -1
  10. package/docs/ai-reference/AI-AUTOMATIONS.md +1 -1
  11. package/docs/ai-reference/AI-DEVICES.md +1 -1
  12. package/docs/ai-reference/AI-EVENTS.md +1 -1
  13. package/docs/ai-reference/AI-GROUPING.md +1 -1
  14. package/docs/ai-reference/AI-JOBS.md +1084 -0
  15. package/docs/ai-reference/AI-MEDIA.md +1 -1
  16. package/docs/ai-reference/AI-SETUP.md +1 -1
  17. package/docs/ai-reference/AI-USERS.md +1 -1
  18. package/examples/vue-jobs/.env.example +11 -0
  19. package/examples/vue-jobs/README.md +245 -0
  20. package/examples/vue-jobs/e2e/app.spec.ts +79 -0
  21. package/examples/vue-jobs/e2e/auth.spec.ts +382 -0
  22. package/examples/vue-jobs/e2e/delete-features.spec.ts +564 -0
  23. package/examples/vue-jobs/e2e/timelapse.spec.ts +361 -0
  24. package/examples/vue-jobs/index.html +13 -0
  25. package/examples/vue-jobs/package-lock.json +1722 -0
  26. package/examples/vue-jobs/package.json +28 -0
  27. package/examples/vue-jobs/playwright.config.ts +47 -0
  28. package/examples/vue-jobs/src/App.vue +154 -0
  29. package/examples/vue-jobs/src/main.ts +25 -0
  30. package/examples/vue-jobs/src/router/index.ts +82 -0
  31. package/examples/vue-jobs/src/views/Callback.vue +76 -0
  32. package/examples/vue-jobs/src/views/CreateExport.vue +284 -0
  33. package/examples/vue-jobs/src/views/Files.vue +424 -0
  34. package/examples/vue-jobs/src/views/Home.vue +195 -0
  35. package/examples/vue-jobs/src/views/JobDetail.vue +392 -0
  36. package/examples/vue-jobs/src/views/Jobs.vue +297 -0
  37. package/examples/vue-jobs/src/views/Login.vue +33 -0
  38. package/examples/vue-jobs/src/views/Logout.vue +59 -0
  39. package/examples/vue-jobs/src/vite-env.d.ts +1 -0
  40. package/examples/vue-jobs/tsconfig.json +25 -0
  41. package/examples/vue-jobs/vite.config.ts +12 -0
  42. package/package.json +1 -1
@@ -0,0 +1,564 @@
1
+ import { test, expect, Page } from '@playwright/test'
2
+ import { baseURL } from '../playwright.config'
3
+
4
+ /**
5
+ * E2E tests for delete features in Jobs and Files pages
6
+ *
7
+ * Tests the delete buttons and confirmation dialogs:
8
+ * - Delete buttons are visible in Actions column
9
+ * - Clicking delete shows confirmation dialog
10
+ * - Canceling dialog does not delete the item
11
+ * - Tables use at least 80% of viewport width
12
+ *
13
+ * Note: These tests do NOT actually delete data to preserve test account state.
14
+ * They only verify the UI behavior and confirm dialog functionality.
15
+ */
16
+
17
+ // Timeout constants
18
+ const TIMEOUTS = {
19
+ OAUTH_REDIRECT: 30000,
20
+ ELEMENT_VISIBLE: 15000,
21
+ PASSWORD_VISIBLE: 10000,
22
+ AUTH_COMPLETE: 30000,
23
+ UI_UPDATE: 10000,
24
+ PROXY_CHECK: 5000,
25
+ DIALOG_VISIBLE: 5000
26
+ } as const
27
+
28
+ const TEST_USER = process.env.TEST_USER
29
+ const TEST_PASSWORD = process.env.TEST_PASSWORD
30
+ const PROXY_URL = process.env.VITE_PROXY_URL
31
+
32
+ async function isProxyAccessible(): Promise<boolean> {
33
+ if (!PROXY_URL) return false
34
+ const controller = new AbortController()
35
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUTS.PROXY_CHECK)
36
+
37
+ try {
38
+ const response = await fetch(PROXY_URL, {
39
+ method: 'HEAD',
40
+ signal: controller.signal
41
+ })
42
+ return response.ok || response.status === 404
43
+ } catch {
44
+ return false
45
+ } finally {
46
+ clearTimeout(timeoutId)
47
+ }
48
+ }
49
+
50
+ async function performLogin(page: Page, username: string, password: string): Promise<void> {
51
+ await page.goto('/')
52
+
53
+ await Promise.all([
54
+ page.waitForURL(/.*eagleeyenetworks.com.*/, { timeout: TIMEOUTS.OAUTH_REDIRECT }),
55
+ page.click('[data-testid="login-button"]')
56
+ ])
57
+
58
+ const emailInput = page.locator('#authentication--input__email')
59
+ await emailInput.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE })
60
+ await emailInput.fill(username)
61
+
62
+ await page.getByRole('button', { name: 'Next' }).click()
63
+
64
+ const passwordInput = page.locator('#authentication--input__password')
65
+ await passwordInput.waitFor({ state: 'visible', timeout: TIMEOUTS.PASSWORD_VISIBLE })
66
+ await passwordInput.fill(password)
67
+
68
+ await page.locator('#next, button:has-text("Sign in")').first().click()
69
+
70
+ const baseURLPattern = new RegExp(baseURL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
71
+ await page.waitForURL(baseURLPattern, { timeout: TIMEOUTS.AUTH_COMPLETE })
72
+ }
73
+
74
+ async function clearAuthState(page: Page): Promise<void> {
75
+ try {
76
+ const url = page.url()
77
+ if (url && url.startsWith('http')) {
78
+ await page.evaluate(() => {
79
+ try {
80
+ localStorage.clear()
81
+ sessionStorage.clear()
82
+ } catch {
83
+ // Ignore errors
84
+ }
85
+ })
86
+ }
87
+ } catch {
88
+ // Ignore errors
89
+ }
90
+ }
91
+
92
+ test.describe('Delete Features - Jobs and Files Pages', () => {
93
+ let proxyAccessible = false
94
+
95
+ function skipIfNoProxy() {
96
+ test.skip(!proxyAccessible, 'OAuth proxy not accessible')
97
+ }
98
+
99
+ function skipIfNoCredentials() {
100
+ test.skip(!TEST_USER || !TEST_PASSWORD, 'Test credentials not available')
101
+ }
102
+
103
+ test.beforeAll(async () => {
104
+ proxyAccessible = await isProxyAccessible()
105
+ if (!proxyAccessible) {
106
+ console.log('OAuth proxy not accessible - delete feature tests will be skipped')
107
+ }
108
+ })
109
+
110
+ test.afterEach(async ({ page }) => {
111
+ await clearAuthState(page)
112
+ })
113
+
114
+ test.describe('Jobs Page Delete Feature', () => {
115
+ test('jobs table has delete buttons in actions column', async ({ page }) => {
116
+ skipIfNoProxy()
117
+ skipIfNoCredentials()
118
+
119
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
120
+ await page.click('[data-testid="nav-jobs"]')
121
+ await page.waitForURL('/jobs')
122
+
123
+ // Wait for loading to complete
124
+ await page.waitForFunction(
125
+ () => !document.querySelector('.jobs .loading'),
126
+ { timeout: TIMEOUTS.ELEMENT_VISIBLE }
127
+ ).catch(() => {})
128
+
129
+ // Check if table exists
130
+ const table = page.locator('.jobs table')
131
+ const hasTable = await table.isVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE }).catch(() => false)
132
+
133
+ if (!hasTable) {
134
+ const noJobs = page.locator('.jobs p:has-text("No jobs found")')
135
+ const hasNoJobs = await noJobs.isVisible({ timeout: 2000 }).catch(() => false)
136
+ if (hasNoJobs) {
137
+ console.log('No jobs found - skipping delete button test')
138
+ return
139
+ }
140
+ throw new Error('Neither jobs table nor "No jobs found" message visible')
141
+ }
142
+
143
+ // Check for delete buttons in actions column
144
+ const deleteButtons = table.locator('tbody tr td.actions button.btn-danger')
145
+ const buttonCount = await deleteButtons.count()
146
+ console.log(`Found ${buttonCount} delete buttons in jobs table`)
147
+
148
+ expect(buttonCount).toBeGreaterThan(0)
149
+
150
+ // Verify first delete button has correct text
151
+ const firstDeleteButton = deleteButtons.first()
152
+ await expect(firstDeleteButton).toHaveText('Delete')
153
+ await expect(firstDeleteButton).toBeEnabled()
154
+ })
155
+
156
+ test('jobs delete button shows confirmation dialog', async ({ page }) => {
157
+ skipIfNoProxy()
158
+ skipIfNoCredentials()
159
+
160
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
161
+ await page.click('[data-testid="nav-jobs"]')
162
+ await page.waitForURL('/jobs')
163
+
164
+ await page.waitForFunction(
165
+ () => !document.querySelector('.jobs .loading'),
166
+ { timeout: TIMEOUTS.ELEMENT_VISIBLE }
167
+ ).catch(() => {})
168
+
169
+ const table = page.locator('.jobs table')
170
+ const hasTable = await table.isVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE }).catch(() => false)
171
+
172
+ if (!hasTable) {
173
+ console.log('No jobs table visible - skipping confirmation dialog test')
174
+ return
175
+ }
176
+
177
+ const deleteButtons = table.locator('tbody tr td.actions button.btn-danger')
178
+ const buttonCount = await deleteButtons.count()
179
+
180
+ if (buttonCount === 0) {
181
+ console.log('No delete buttons found - skipping confirmation dialog test')
182
+ return
183
+ }
184
+
185
+ // Set up dialog handler to capture and dismiss
186
+ let dialogMessage = ''
187
+ page.on('dialog', async dialog => {
188
+ dialogMessage = dialog.message()
189
+ console.log('Dialog message:', dialogMessage)
190
+ await dialog.dismiss() // Cancel the delete
191
+ })
192
+
193
+ // Click the first delete button
194
+ await deleteButtons.first().click()
195
+
196
+ // Wait a moment for dialog
197
+ await page.waitForTimeout(500)
198
+
199
+ // Verify dialog was shown with confirmation message
200
+ expect(dialogMessage).toContain('Are you sure')
201
+ expect(dialogMessage).toContain('delete')
202
+ })
203
+
204
+ test('canceling jobs delete dialog does not remove the job', async ({ page }) => {
205
+ skipIfNoProxy()
206
+ skipIfNoCredentials()
207
+
208
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
209
+ await page.click('[data-testid="nav-jobs"]')
210
+ await page.waitForURL('/jobs')
211
+
212
+ await page.waitForFunction(
213
+ () => !document.querySelector('.jobs .loading'),
214
+ { timeout: TIMEOUTS.ELEMENT_VISIBLE }
215
+ ).catch(() => {})
216
+
217
+ const table = page.locator('.jobs table')
218
+ const hasTable = await table.isVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE }).catch(() => false)
219
+
220
+ if (!hasTable) {
221
+ console.log('No jobs table visible - skipping cancel test')
222
+ return
223
+ }
224
+
225
+ // Count rows before
226
+ const rows = table.locator('tbody tr')
227
+ const rowCountBefore = await rows.count()
228
+ console.log(`Jobs before cancel: ${rowCountBefore}`)
229
+
230
+ if (rowCountBefore === 0) {
231
+ console.log('No jobs found - skipping cancel test')
232
+ return
233
+ }
234
+
235
+ // Get the first job's name for verification
236
+ const firstJobName = await rows.first().locator('td').first().textContent()
237
+
238
+ // Set up dialog handler to dismiss (cancel)
239
+ page.on('dialog', async dialog => {
240
+ await dialog.dismiss()
241
+ })
242
+
243
+ // Click delete button
244
+ const deleteButton = table.locator('tbody tr td.actions button.btn-danger').first()
245
+ await deleteButton.click()
246
+
247
+ // Wait a moment
248
+ await page.waitForTimeout(500)
249
+
250
+ // Count rows after - should be the same
251
+ const rowCountAfter = await rows.count()
252
+ console.log(`Jobs after cancel: ${rowCountAfter}`)
253
+
254
+ expect(rowCountAfter).toBe(rowCountBefore)
255
+
256
+ // Verify first job is still there
257
+ const firstJobNameAfter = await rows.first().locator('td').first().textContent()
258
+ expect(firstJobNameAfter).toBe(firstJobName)
259
+ console.log('Job was NOT deleted after canceling dialog')
260
+ })
261
+
262
+ test('jobs table uses at least 80% of viewport width', async ({ page }) => {
263
+ skipIfNoProxy()
264
+ skipIfNoCredentials()
265
+
266
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
267
+ await page.click('[data-testid="nav-jobs"]')
268
+ await page.waitForURL('/jobs')
269
+
270
+ await page.waitForFunction(
271
+ () => !document.querySelector('.jobs .loading'),
272
+ { timeout: TIMEOUTS.ELEMENT_VISIBLE }
273
+ ).catch(() => {})
274
+
275
+ // Get the jobs container width
276
+ const jobsContainer = page.locator('.jobs')
277
+ const isVisible = await jobsContainer.isVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE }).catch(() => false)
278
+
279
+ if (!isVisible) {
280
+ console.log('Jobs container not visible - skipping width test')
281
+ return
282
+ }
283
+
284
+ const viewportSize = page.viewportSize()
285
+ if (!viewportSize) {
286
+ console.log('Could not get viewport size - skipping width test')
287
+ return
288
+ }
289
+
290
+ const containerBox = await jobsContainer.boundingBox()
291
+ if (!containerBox) {
292
+ console.log('Could not get container bounding box - skipping width test')
293
+ return
294
+ }
295
+
296
+ const widthPercentage = (containerBox.width / viewportSize.width) * 100
297
+ console.log(`Jobs container width: ${containerBox.width}px (${widthPercentage.toFixed(1)}% of viewport)`)
298
+
299
+ // Should be at least 80% of viewport width
300
+ expect(widthPercentage).toBeGreaterThanOrEqual(80)
301
+ })
302
+ })
303
+
304
+ test.describe('Files Page Delete Feature', () => {
305
+ test('files table has delete buttons in actions column', async ({ page }) => {
306
+ skipIfNoProxy()
307
+ skipIfNoCredentials()
308
+
309
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
310
+ await page.click('[data-testid="nav-files"]')
311
+ await page.waitForURL('/files')
312
+
313
+ await page.waitForFunction(
314
+ () => !document.querySelector('.files .loading'),
315
+ { timeout: TIMEOUTS.ELEMENT_VISIBLE }
316
+ ).catch(() => {})
317
+
318
+ const table = page.locator('.files table')
319
+ const hasTable = await table.isVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE }).catch(() => false)
320
+
321
+ if (!hasTable) {
322
+ const noFiles = page.locator('.files p:has-text("No files found")')
323
+ const hasNoFiles = await noFiles.isVisible({ timeout: 2000 }).catch(() => false)
324
+ if (hasNoFiles) {
325
+ console.log('No files found - skipping delete button test')
326
+ return
327
+ }
328
+ throw new Error('Neither files table nor "No files found" message visible')
329
+ }
330
+
331
+ // Check for delete buttons in actions column
332
+ const deleteButtons = table.locator('tbody tr td.actions button.btn-danger')
333
+ const buttonCount = await deleteButtons.count()
334
+ console.log(`Found ${buttonCount} delete buttons in files table`)
335
+
336
+ expect(buttonCount).toBeGreaterThan(0)
337
+
338
+ // Verify first delete button has correct text
339
+ const firstDeleteButton = deleteButtons.first()
340
+ await expect(firstDeleteButton).toHaveText('Delete')
341
+ await expect(firstDeleteButton).toBeEnabled()
342
+ })
343
+
344
+ test('files delete button shows confirmation dialog', async ({ page }) => {
345
+ skipIfNoProxy()
346
+ skipIfNoCredentials()
347
+
348
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
349
+ await page.click('[data-testid="nav-files"]')
350
+ await page.waitForURL('/files')
351
+
352
+ await page.waitForFunction(
353
+ () => !document.querySelector('.files .loading'),
354
+ { timeout: TIMEOUTS.ELEMENT_VISIBLE }
355
+ ).catch(() => {})
356
+
357
+ const table = page.locator('.files table')
358
+ const hasTable = await table.isVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE }).catch(() => false)
359
+
360
+ if (!hasTable) {
361
+ console.log('No files table visible - skipping confirmation dialog test')
362
+ return
363
+ }
364
+
365
+ const deleteButtons = table.locator('tbody tr td.actions button.btn-danger')
366
+ const buttonCount = await deleteButtons.count()
367
+
368
+ if (buttonCount === 0) {
369
+ console.log('No delete buttons found - skipping confirmation dialog test')
370
+ return
371
+ }
372
+
373
+ // Set up dialog handler to capture and dismiss
374
+ let dialogMessage = ''
375
+ page.on('dialog', async dialog => {
376
+ dialogMessage = dialog.message()
377
+ console.log('Dialog message:', dialogMessage)
378
+ await dialog.dismiss() // Cancel the delete
379
+ })
380
+
381
+ // Click the first delete button
382
+ await deleteButtons.first().click()
383
+
384
+ // Wait a moment for dialog
385
+ await page.waitForTimeout(500)
386
+
387
+ // Verify dialog was shown with confirmation message
388
+ expect(dialogMessage).toContain('Are you sure')
389
+ expect(dialogMessage).toContain('delete')
390
+ expect(dialogMessage).toContain('recycle bin')
391
+ })
392
+
393
+ test('canceling files delete dialog does not remove the file', async ({ page }) => {
394
+ skipIfNoProxy()
395
+ skipIfNoCredentials()
396
+
397
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
398
+ await page.click('[data-testid="nav-files"]')
399
+ await page.waitForURL('/files')
400
+
401
+ await page.waitForFunction(
402
+ () => !document.querySelector('.files .loading'),
403
+ { timeout: TIMEOUTS.ELEMENT_VISIBLE }
404
+ ).catch(() => {})
405
+
406
+ const table = page.locator('.files table')
407
+ const hasTable = await table.isVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE }).catch(() => false)
408
+
409
+ if (!hasTable) {
410
+ console.log('No files table visible - skipping cancel test')
411
+ return
412
+ }
413
+
414
+ // Count rows before
415
+ const rows = table.locator('tbody tr')
416
+ const rowCountBefore = await rows.count()
417
+ console.log(`Files before cancel: ${rowCountBefore}`)
418
+
419
+ if (rowCountBefore === 0) {
420
+ console.log('No files found - skipping cancel test')
421
+ return
422
+ }
423
+
424
+ // Get the first file's name for verification
425
+ const firstFileName = await rows.first().locator('td').first().textContent()
426
+
427
+ // Set up dialog handler to dismiss (cancel)
428
+ page.on('dialog', async dialog => {
429
+ await dialog.dismiss()
430
+ })
431
+
432
+ // Click delete button
433
+ const deleteButton = table.locator('tbody tr td.actions button.btn-danger').first()
434
+ await deleteButton.click()
435
+
436
+ // Wait a moment
437
+ await page.waitForTimeout(500)
438
+
439
+ // Count rows after - should be the same
440
+ const rowCountAfter = await rows.count()
441
+ console.log(`Files after cancel: ${rowCountAfter}`)
442
+
443
+ expect(rowCountAfter).toBe(rowCountBefore)
444
+
445
+ // Verify first file is still there
446
+ const firstFileNameAfter = await rows.first().locator('td').first().textContent()
447
+ expect(firstFileNameAfter).toBe(firstFileName)
448
+ console.log('File was NOT deleted after canceling dialog')
449
+ })
450
+
451
+ test('files table uses at least 80% of viewport width', async ({ page }) => {
452
+ skipIfNoProxy()
453
+ skipIfNoCredentials()
454
+
455
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
456
+ await page.click('[data-testid="nav-files"]')
457
+ await page.waitForURL('/files')
458
+
459
+ await page.waitForFunction(
460
+ () => !document.querySelector('.files .loading'),
461
+ { timeout: TIMEOUTS.ELEMENT_VISIBLE }
462
+ ).catch(() => {})
463
+
464
+ // Get the files container width
465
+ const filesContainer = page.locator('.files')
466
+ const isVisible = await filesContainer.isVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE }).catch(() => false)
467
+
468
+ if (!isVisible) {
469
+ console.log('Files container not visible - skipping width test')
470
+ return
471
+ }
472
+
473
+ const viewportSize = page.viewportSize()
474
+ if (!viewportSize) {
475
+ console.log('Could not get viewport size - skipping width test')
476
+ return
477
+ }
478
+
479
+ const containerBox = await filesContainer.boundingBox()
480
+ if (!containerBox) {
481
+ console.log('Could not get container bounding box - skipping width test')
482
+ return
483
+ }
484
+
485
+ const widthPercentage = (containerBox.width / viewportSize.width) * 100
486
+ console.log(`Files container width: ${containerBox.width}px (${widthPercentage.toFixed(1)}% of viewport)`)
487
+
488
+ // Should be at least 80% of viewport width
489
+ expect(widthPercentage).toBeGreaterThanOrEqual(80)
490
+ })
491
+ })
492
+
493
+ test.describe('Actions Column Layout', () => {
494
+ test('jobs actions column has view and delete buttons side by side', async ({ page }) => {
495
+ skipIfNoProxy()
496
+ skipIfNoCredentials()
497
+
498
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
499
+ await page.click('[data-testid="nav-jobs"]')
500
+ await page.waitForURL('/jobs')
501
+
502
+ await page.waitForFunction(
503
+ () => !document.querySelector('.jobs .loading'),
504
+ { timeout: TIMEOUTS.ELEMENT_VISIBLE }
505
+ ).catch(() => {})
506
+
507
+ const table = page.locator('.jobs table')
508
+ const hasTable = await table.isVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE }).catch(() => false)
509
+
510
+ if (!hasTable) {
511
+ console.log('No jobs table visible - skipping layout test')
512
+ return
513
+ }
514
+
515
+ // Check first row's actions cell
516
+ const actionsCell = table.locator('tbody tr').first().locator('td.actions')
517
+ const buttons = actionsCell.locator('button')
518
+ const buttonCount = await buttons.count()
519
+
520
+ expect(buttonCount).toBe(2) // View and Delete
521
+
522
+ // Verify button labels
523
+ await expect(buttons.nth(0)).toHaveText('View')
524
+ await expect(buttons.nth(1)).toHaveText('Delete')
525
+
526
+ console.log('Jobs actions column has View and Delete buttons')
527
+ })
528
+
529
+ test('files actions column has download and delete buttons side by side', async ({ page }) => {
530
+ skipIfNoProxy()
531
+ skipIfNoCredentials()
532
+
533
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
534
+ await page.click('[data-testid="nav-files"]')
535
+ await page.waitForURL('/files')
536
+
537
+ await page.waitForFunction(
538
+ () => !document.querySelector('.files .loading'),
539
+ { timeout: TIMEOUTS.ELEMENT_VISIBLE }
540
+ ).catch(() => {})
541
+
542
+ const table = page.locator('.files table')
543
+ const hasTable = await table.isVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE }).catch(() => false)
544
+
545
+ if (!hasTable) {
546
+ console.log('No files table visible - skipping layout test')
547
+ return
548
+ }
549
+
550
+ // Check first row's actions cell
551
+ const actionsCell = table.locator('tbody tr').first().locator('td.actions')
552
+ const buttons = actionsCell.locator('button')
553
+ const buttonCount = await buttons.count()
554
+
555
+ expect(buttonCount).toBe(2) // Download and Delete
556
+
557
+ // Verify button labels
558
+ await expect(buttons.nth(0)).toHaveText('Download')
559
+ await expect(buttons.nth(1)).toHaveText('Delete')
560
+
561
+ console.log('Files actions column has Download and Delete buttons')
562
+ })
563
+ })
564
+ })