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,382 @@
1
+ import { test, expect, Page } from '@playwright/test'
2
+ import { baseURL } from '../playwright.config'
3
+
4
+ /**
5
+ * E2E tests for the Vue Jobs Example
6
+ *
7
+ * Tests the OAuth login flow through the UI:
8
+ * 1. Click login button in the example app
9
+ * 2. Enter credentials on EEN OAuth page
10
+ * 3. Complete the OAuth callback
11
+ * 4. Verify authenticated state
12
+ *
13
+ * Required environment variables:
14
+ * - VITE_PROXY_URL: OAuth proxy URL (e.g., http://127.0.0.1:8787)
15
+ * - VITE_EEN_CLIENT_ID: EEN OAuth client ID
16
+ * - TEST_USER: Test user email
17
+ * - TEST_PASSWORD: Test user password
18
+ */
19
+
20
+ // Timeout constants for consistent behavior
21
+ // Values chosen based on OAuth flow timing requirements
22
+ const TIMEOUTS = {
23
+ OAUTH_REDIRECT: 30000, // OAuth redirects can be slow on first load
24
+ ELEMENT_VISIBLE: 15000, // Wait for OAuth page elements to render
25
+ PASSWORD_VISIBLE: 10000, // Password field appears after email validation
26
+ AUTH_COMPLETE: 30000, // Full OAuth flow completion
27
+ UI_UPDATE: 10000, // UI state updates after auth changes
28
+ PROXY_CHECK: 5000 // Quick check if proxy is running
29
+ } as const
30
+
31
+ const TEST_USER = process.env.TEST_USER
32
+ const TEST_PASSWORD = process.env.TEST_PASSWORD
33
+ const PROXY_URL = process.env.VITE_PROXY_URL
34
+
35
+ /**
36
+ * Checks if the OAuth proxy is accessible.
37
+ * Returns true if proxy responds (even with 404), false if unreachable.
38
+ */
39
+ async function isProxyAccessible(): Promise<boolean> {
40
+ if (!PROXY_URL) return false
41
+ const controller = new AbortController()
42
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUTS.PROXY_CHECK)
43
+
44
+ try {
45
+ const response = await fetch(PROXY_URL, {
46
+ method: 'HEAD',
47
+ signal: controller.signal
48
+ })
49
+ // 404 is ok - means proxy is running but endpoint doesn't exist
50
+ return response.ok || response.status === 404
51
+ } catch {
52
+ return false
53
+ } finally {
54
+ clearTimeout(timeoutId)
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Performs OAuth login flow through the UI.
60
+ * Starts from home page and completes full OAuth authentication.
61
+ */
62
+ async function performLogin(page: Page, username: string, password: string): Promise<void> {
63
+ // Start at home page
64
+ await page.goto('/')
65
+
66
+ // Click login button and wait for OAuth redirect
67
+ await Promise.all([
68
+ page.waitForURL(/.*eagleeyenetworks.com.*/, { timeout: TIMEOUTS.OAUTH_REDIRECT }),
69
+ page.click('[data-testid="login-button"]')
70
+ ])
71
+
72
+ // Fill email
73
+ const emailInput = page.locator('#authentication--input__email')
74
+ await emailInput.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE })
75
+ await emailInput.fill(username)
76
+
77
+ // Click next
78
+ await page.getByRole('button', { name: 'Next' }).click()
79
+
80
+ // Fill password
81
+ const passwordInput = page.locator('#authentication--input__password')
82
+ await passwordInput.waitFor({ state: 'visible', timeout: TIMEOUTS.PASSWORD_VISIBLE })
83
+ await passwordInput.fill(password)
84
+
85
+ // Click sign in - use OR selector for robustness
86
+ await page.locator('#next, button:has-text("Sign in")').first().click()
87
+
88
+ // Wait for redirect back to the app using configured baseURL
89
+ const baseURLPattern = new RegExp(baseURL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
90
+ await page.waitForURL(baseURLPattern, { timeout: TIMEOUTS.AUTH_COMPLETE })
91
+ }
92
+
93
+ /**
94
+ * Clears browser storage to reset auth state.
95
+ * Handles cases where localStorage isn't accessible (e.g., about:blank, cross-origin).
96
+ */
97
+ async function clearAuthState(page: Page): Promise<void> {
98
+ try {
99
+ // Only try to clear storage if we're on a page that allows it
100
+ const url = page.url()
101
+ if (url && url.startsWith('http')) {
102
+ await page.evaluate(() => {
103
+ try {
104
+ localStorage.clear()
105
+ sessionStorage.clear()
106
+ } catch {
107
+ // Ignore errors - storage may not be accessible
108
+ }
109
+ })
110
+ }
111
+ } catch {
112
+ // Ignore errors - page may be closed or in an inaccessible state
113
+ }
114
+ }
115
+
116
+ test.describe('Vue Jobs Example', () => {
117
+ // Check proxy accessibility once before all tests
118
+ let proxyAccessible = false
119
+
120
+ // Helper functions to skip tests when prerequisites aren't met
121
+ function skipIfNoProxy() {
122
+ test.skip(!proxyAccessible, 'OAuth proxy not accessible')
123
+ }
124
+
125
+ function skipIfNoCredentials() {
126
+ test.skip(!TEST_USER || !TEST_PASSWORD, 'Test credentials not available')
127
+ }
128
+
129
+ function skipIfNoUser() {
130
+ test.skip(!TEST_USER, 'Test user not available')
131
+ }
132
+
133
+ test.beforeAll(async () => {
134
+ proxyAccessible = await isProxyAccessible()
135
+ if (!proxyAccessible) {
136
+ console.log('OAuth proxy not accessible - OAuth tests will be skipped')
137
+ }
138
+ })
139
+
140
+ test.afterEach(async ({ page }) => {
141
+ // Clear auth state after each test to prevent state pollution
142
+ await clearAuthState(page)
143
+ })
144
+
145
+ test('shows login button when not authenticated', async ({ page }) => {
146
+ await page.goto('/')
147
+ await expect(page.locator('[data-testid="not-authenticated"]')).toBeVisible()
148
+ await expect(page.locator('[data-testid="nav-login"]')).toBeVisible()
149
+ await expect(page.locator('[data-testid="nav-logout"]')).not.toBeVisible()
150
+ })
151
+
152
+ test('jobs page redirects to login without authentication', async ({ page }) => {
153
+ await page.goto('/jobs')
154
+ await page.waitForURL('/login')
155
+ await expect(page.locator('[data-testid="login-title"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
156
+ })
157
+
158
+ test('login button redirects to OAuth page', async ({ page }) => {
159
+ skipIfNoProxy()
160
+ skipIfNoCredentials()
161
+
162
+ await page.goto('/')
163
+ await expect(page.locator('[data-testid="login-button"]')).toBeVisible()
164
+ await expect(page.locator('[data-testid="login-button"]')).toBeEnabled()
165
+
166
+ // Click login and verify redirect to OAuth page
167
+ await Promise.all([
168
+ page.waitForURL(/.*eagleeyenetworks.com.*/, { timeout: TIMEOUTS.OAUTH_REDIRECT }),
169
+ page.click('[data-testid="login-button"]')
170
+ ])
171
+
172
+ // Verify we're on the OAuth page
173
+ const emailInput = page.locator('#authentication--input__email')
174
+ await expect(emailInput).toBeVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE })
175
+ })
176
+
177
+ test('complete OAuth login flow', async ({ page }) => {
178
+ skipIfNoProxy()
179
+ skipIfNoCredentials()
180
+
181
+ // Verify initially not authenticated
182
+ await page.goto('/')
183
+ await expect(page.locator('[data-testid="not-authenticated"]')).toBeVisible()
184
+
185
+ // Perform login
186
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
187
+
188
+ // Verify authenticated state
189
+ await expect(page.locator('[data-testid="not-authenticated"]')).not.toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
190
+ await expect(page.locator('[data-testid="nav-jobs"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
191
+ await expect(page.locator('[data-testid="nav-files"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
192
+ await expect(page.locator('[data-testid="nav-create-export"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
193
+ await expect(page.locator('[data-testid="nav-logout"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
194
+ await expect(page.locator('[data-testid="nav-login"]')).not.toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
195
+ })
196
+
197
+ test('can view jobs list after login', async ({ page }) => {
198
+ skipIfNoProxy()
199
+ skipIfNoCredentials()
200
+
201
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
202
+ await expect(page.locator('[data-testid="nav-jobs"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
203
+
204
+ // Navigate to jobs page
205
+ await page.click('[data-testid="nav-jobs"]')
206
+ await page.waitForURL('/jobs')
207
+
208
+ // Should see jobs content (table or "No jobs found" message)
209
+ await expect(
210
+ page.locator('.jobs table, .jobs p:has-text("No jobs found")').first()
211
+ ).toBeVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE })
212
+ await expect(page.locator('.error')).not.toBeVisible()
213
+ })
214
+
215
+ test('can view files list after login', async ({ page }) => {
216
+ skipIfNoProxy()
217
+ skipIfNoCredentials()
218
+
219
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
220
+ await expect(page.locator('[data-testid="nav-files"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
221
+
222
+ // Navigate to files page
223
+ await page.click('[data-testid="nav-files"]')
224
+ await page.waitForURL('/files')
225
+
226
+ // Should see files content (table or "No files found" message)
227
+ await expect(
228
+ page.locator('.files table, .files p:has-text("No files found")').first()
229
+ ).toBeVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE })
230
+ })
231
+
232
+ test('files list displays valid data (type, size, date)', async ({ page }) => {
233
+ skipIfNoProxy()
234
+ skipIfNoCredentials()
235
+
236
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
237
+ await page.click('[data-testid="nav-files"]')
238
+ await page.waitForURL('/files')
239
+
240
+ // Wait for either loading to finish or content to appear
241
+ // First wait for loading state to clear
242
+ await page.waitForFunction(
243
+ () => !document.querySelector('.files .loading'),
244
+ { timeout: TIMEOUTS.ELEMENT_VISIBLE }
245
+ ).catch(() => {})
246
+
247
+ // Wait for files table to be visible
248
+ const table = page.locator('.files table')
249
+ const hasTable = await table.isVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE }).catch(() => false)
250
+
251
+ if (!hasTable) {
252
+ // Check if "No files found" is shown
253
+ const noFiles = page.locator('.files p:has-text("No files found")')
254
+ const hasNoFiles = await noFiles.isVisible({ timeout: 2000 }).catch(() => false)
255
+ if (hasNoFiles) {
256
+ console.log('No files found message visible - skipping data validation')
257
+ } else {
258
+ console.log('No files table or message visible - skipping data validation')
259
+ }
260
+ return
261
+ }
262
+
263
+ // Get all data rows
264
+ const rows = table.locator('tbody tr')
265
+ const rowCount = await rows.count()
266
+ console.log(`Found ${rowCount} files in the table`)
267
+
268
+ // Validate each row has proper metadata
269
+ let foundFileWithSize = false
270
+ for (let i = 0; i < Math.min(rowCount, 10); i++) {
271
+ const row = rows.nth(i)
272
+ const cells = row.locator('td')
273
+ const name = await cells.nth(0).textContent()
274
+ const type = await cells.nth(1).textContent()
275
+ const size = await cells.nth(2).textContent()
276
+ const date = await cells.nth(3).textContent()
277
+
278
+ console.log(`Row ${i + 1}:`, { name: name?.substring(0, 40), type, size, date: date?.substring(0, 20) })
279
+
280
+ // Validate name is not empty
281
+ expect(name).toBeTruthy()
282
+ expect(name!.trim().length).toBeGreaterThan(0)
283
+
284
+ // Validate type is not empty (folder, video, image, etc.)
285
+ expect(type).toBeTruthy()
286
+ expect(type!.trim().length).toBeGreaterThan(0)
287
+
288
+ // Validate size format: "-" for folders, or valid size (e.g., "1.5 MB")
289
+ expect(size).toBeTruthy()
290
+ const sizeValue = size!.trim()
291
+ expect(sizeValue).toMatch(/^(-|\d+(\.\d+)?\s*(B|KB|MB|GB))$/)
292
+
293
+ // Track if we found a file with actual size (not a folder)
294
+ if (sizeValue !== '-') {
295
+ foundFileWithSize = true
296
+ console.log(` -> Found file with size: ${sizeValue}`)
297
+ }
298
+
299
+ // Validate date is valid (not "Invalid Date")
300
+ expect(date).toBeTruthy()
301
+ expect(date!.trim()).not.toBe('Invalid Date')
302
+ expect(date!.trim().length).toBeGreaterThan(0)
303
+ }
304
+
305
+ // Ensure we found at least one file with actual size data
306
+ // This validates the size field is being returned from the API
307
+ if (!foundFileWithSize && rowCount > 0) {
308
+ console.log('Note: All visible files are folders (no size). This is valid but size display not fully verified.')
309
+ } else if (foundFileWithSize) {
310
+ console.log('✓ Successfully verified file size display')
311
+ }
312
+ })
313
+
314
+ test('can access create export page after login', async ({ page }) => {
315
+ skipIfNoProxy()
316
+ skipIfNoCredentials()
317
+
318
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
319
+ await expect(page.locator('[data-testid="nav-create-export"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
320
+
321
+ // Navigate to create export page
322
+ await page.click('[data-testid="nav-create-export"]')
323
+ await page.waitForURL('/create-export')
324
+
325
+ // Should see the create export form or loading state
326
+ await expect(
327
+ page.locator('.create-export h2:has-text("Create Export")').first()
328
+ ).toBeVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE })
329
+ })
330
+
331
+ test('can logout after login', async ({ page }) => {
332
+ skipIfNoProxy()
333
+ skipIfNoCredentials()
334
+
335
+ await performLogin(page, TEST_USER!, TEST_PASSWORD!)
336
+ await expect(page.locator('[data-testid="nav-logout"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
337
+
338
+ // Click logout
339
+ await page.click('[data-testid="nav-logout"]')
340
+
341
+ // Should show not authenticated - wait for redirect to app baseURL
342
+ const baseURLPattern = new RegExp(baseURL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
343
+ await page.waitForURL(baseURLPattern)
344
+ await expect(page.locator('[data-testid="not-authenticated"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
345
+ await expect(page.locator('[data-testid="nav-login"]')).toBeVisible()
346
+ })
347
+
348
+ test('invalid password shows error on OAuth page', async ({ page }) => {
349
+ skipIfNoProxy()
350
+ skipIfNoUser()
351
+
352
+ await page.goto('/')
353
+
354
+ // Click login and wait for OAuth redirect
355
+ await Promise.all([
356
+ page.waitForURL(/.*eagleeyenetworks.com.*/, { timeout: TIMEOUTS.OAUTH_REDIRECT }),
357
+ page.click('[data-testid="login-button"]')
358
+ ])
359
+
360
+ // Fill valid email
361
+ const emailInput = page.locator('#authentication--input__email')
362
+ await emailInput.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE })
363
+ await emailInput.fill(TEST_USER!)
364
+ await page.getByRole('button', { name: 'Next' }).click()
365
+
366
+ // Fill invalid password
367
+ const passwordInput = page.locator('#authentication--input__password')
368
+ await passwordInput.waitFor({ state: 'visible', timeout: TIMEOUTS.PASSWORD_VISIBLE })
369
+ await passwordInput.fill('invalid-password-12345!')
370
+
371
+ // Click sign in
372
+ await page.locator('#next, button:has-text("Sign in")').first().click()
373
+
374
+ // Should show error message on OAuth page
375
+ await expect(
376
+ page.locator('.error, [class*="error"], [data-testid*="error"], #error, .alert-danger').first()
377
+ ).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
378
+
379
+ // Should still be on OAuth page
380
+ await expect(page).toHaveURL(/eagleeyenetworks\.com/)
381
+ })
382
+ })