een-api-toolkit 0.1.2 → 0.1.7
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.
- package/CHANGELOG.md +10 -53
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +686 -1
- package/dist/index.js +457 -222
- package/dist/index.js.map +1 -1
- package/docs/AI-CONTEXT.md +293 -1
- package/examples/vue-bridges/.env.example +13 -0
- package/examples/vue-bridges/e2e/app.spec.ts +73 -0
- package/examples/vue-bridges/e2e/auth.spec.ts +206 -0
- package/examples/vue-bridges/index.html +13 -0
- package/examples/vue-bridges/package-lock.json +1583 -0
- package/examples/vue-bridges/package.json +28 -0
- package/examples/vue-bridges/playwright.config.ts +46 -0
- package/examples/vue-bridges/src/App.vue +108 -0
- package/examples/vue-bridges/src/main.ts +23 -0
- package/examples/vue-bridges/src/router/index.ts +68 -0
- package/examples/vue-bridges/src/views/BridgeDetail.vue +279 -0
- package/examples/vue-bridges/src/views/Bridges.vue +297 -0
- package/examples/vue-bridges/src/views/Callback.vue +76 -0
- package/examples/vue-bridges/src/views/Home.vue +150 -0
- package/examples/vue-bridges/src/views/Login.vue +33 -0
- package/examples/vue-bridges/src/views/Logout.vue +66 -0
- package/examples/vue-bridges/src/vite-env.d.ts +12 -0
- package/examples/vue-bridges/tsconfig.json +21 -0
- package/examples/vue-bridges/tsconfig.node.json +10 -0
- package/examples/vue-bridges/vite.config.ts +12 -0
- package/examples/vue-media/.env.example +5 -0
- package/examples/vue-media/e2e/app.spec.ts +55 -0
- package/examples/vue-media/e2e/auth.spec.ts +344 -0
- package/examples/vue-media/index.html +13 -0
- package/examples/vue-media/package-lock.json +1583 -0
- package/examples/vue-media/package.json +28 -0
- package/examples/vue-media/playwright.config.ts +28 -0
- package/examples/vue-media/src/App.vue +122 -0
- package/examples/vue-media/src/main.ts +22 -0
- package/examples/vue-media/src/router/index.ts +61 -0
- package/examples/vue-media/src/views/Callback.vue +76 -0
- package/examples/vue-media/src/views/Home.vue +86 -0
- package/examples/vue-media/src/views/LiveCamera.vue +330 -0
- package/examples/vue-media/src/views/Login.vue +32 -0
- package/examples/vue-media/src/views/Logout.vue +59 -0
- package/examples/vue-media/src/vite-env.d.ts +12 -0
- package/examples/vue-media/tsconfig.json +21 -0
- package/examples/vue-media/tsconfig.node.json +10 -0
- package/examples/vue-media/vite.config.ts +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
test.describe('vue-media example app', () => {
|
|
4
|
+
test('home page shows login button when not authenticated', async ({ page }) => {
|
|
5
|
+
await page.goto('/')
|
|
6
|
+
|
|
7
|
+
// Should show the home page
|
|
8
|
+
await expect(page.getByRole('heading', { name: 'Welcome to the EEN Media Example' })).toBeVisible()
|
|
9
|
+
|
|
10
|
+
// Should show "not authenticated" state with login button
|
|
11
|
+
await expect(page.getByTestId('not-authenticated')).toBeVisible()
|
|
12
|
+
await expect(page.getByTestId('login-button')).toBeVisible()
|
|
13
|
+
await expect(page.getByText('Please log in to view live camera images')).toBeVisible()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('login button navigates to login page', async ({ page }) => {
|
|
17
|
+
await page.goto('/')
|
|
18
|
+
|
|
19
|
+
await page.getByTestId('login-button').click()
|
|
20
|
+
|
|
21
|
+
await expect(page).toHaveURL('/login')
|
|
22
|
+
await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible()
|
|
23
|
+
await expect(page.getByText('Click the button below to authenticate with Eagle Eye Networks')).toBeVisible()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('live route redirects to login when not authenticated', async ({ page }) => {
|
|
27
|
+
await page.goto('/live')
|
|
28
|
+
|
|
29
|
+
// Should redirect to login page (auth guard)
|
|
30
|
+
await expect(page).toHaveURL('/login')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('navigation links work correctly', async ({ page }) => {
|
|
34
|
+
await page.goto('/')
|
|
35
|
+
|
|
36
|
+
// Check navigation is present
|
|
37
|
+
await expect(page.getByRole('navigation')).toBeVisible()
|
|
38
|
+
|
|
39
|
+
// Navigate to login via nav link
|
|
40
|
+
await page.getByRole('link', { name: 'Login' }).click()
|
|
41
|
+
await expect(page).toHaveURL('/login')
|
|
42
|
+
|
|
43
|
+
// Navigate back home
|
|
44
|
+
await page.getByRole('link', { name: 'Home' }).click()
|
|
45
|
+
await expect(page).toHaveURL('/')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('about section displays toolkit function list', async ({ page }) => {
|
|
49
|
+
await page.goto('/')
|
|
50
|
+
|
|
51
|
+
// Check for the function descriptions
|
|
52
|
+
await expect(page.getByText('getCameras()')).toBeVisible()
|
|
53
|
+
await expect(page.getByText('getLiveImage()')).toBeVisible()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated E2E tests for vue-media example
|
|
3
|
+
*
|
|
4
|
+
* These tests require:
|
|
5
|
+
* - TEST_USER and TEST_PASSWORD environment variables
|
|
6
|
+
* - VITE_EEN_CLIENT_ID environment variable
|
|
7
|
+
* - Running OAuth proxy server (see ../../../scripts/restart-proxy.sh)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test, expect, chromium } from '@playwright/test'
|
|
11
|
+
import * as fs from 'fs'
|
|
12
|
+
import * as path from 'path'
|
|
13
|
+
import { fileURLToPath } from 'url'
|
|
14
|
+
import dotenv from 'dotenv'
|
|
15
|
+
|
|
16
|
+
// Load environment variables from root .env
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
const rootDir = path.resolve(__dirname, '../../..')
|
|
19
|
+
dotenv.config({ path: path.join(rootDir, '.env') })
|
|
20
|
+
|
|
21
|
+
const PROXY_URL = process.env.VITE_PROXY_URL || 'http://localhost:8787'
|
|
22
|
+
const CLIENT_ID = process.env.VITE_EEN_CLIENT_ID
|
|
23
|
+
const REDIRECT_URI = 'http://127.0.0.1:3333'
|
|
24
|
+
const AUTH_CACHE_FILE = path.join(__dirname, '.auth-state.json')
|
|
25
|
+
|
|
26
|
+
interface AuthState {
|
|
27
|
+
token: string
|
|
28
|
+
tokenExpiration: number
|
|
29
|
+
baseUrl: string
|
|
30
|
+
sessionId: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface TokenResponse {
|
|
34
|
+
accessToken: string
|
|
35
|
+
expiresIn: number
|
|
36
|
+
httpsBaseUrl: string | { hostname: string; port?: number }
|
|
37
|
+
sessionId: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Load cached auth state if valid
|
|
42
|
+
*/
|
|
43
|
+
function loadCachedAuth(): AuthState | null {
|
|
44
|
+
if (!fs.existsSync(AUTH_CACHE_FILE)) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const data = fs.readFileSync(AUTH_CACHE_FILE, 'utf-8')
|
|
49
|
+
const auth = JSON.parse(data) as AuthState
|
|
50
|
+
const bufferMs = 5 * 60 * 1000
|
|
51
|
+
if (Date.now() + bufferMs < auth.tokenExpiration) {
|
|
52
|
+
return auth
|
|
53
|
+
}
|
|
54
|
+
return null
|
|
55
|
+
} catch {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Perform OAuth login and return auth state
|
|
62
|
+
*/
|
|
63
|
+
async function performLogin(): Promise<AuthState> {
|
|
64
|
+
const username = process.env.TEST_USER
|
|
65
|
+
const password = process.env.TEST_PASSWORD
|
|
66
|
+
|
|
67
|
+
if (!username || !password) {
|
|
68
|
+
throw new Error('TEST_USER and TEST_PASSWORD must be set in .env')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!CLIENT_ID) {
|
|
72
|
+
throw new Error('VITE_EEN_CLIENT_ID must be set in .env')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const browser = await chromium.launch({ headless: true })
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const context = await browser.newContext()
|
|
79
|
+
const page = await context.newPage()
|
|
80
|
+
|
|
81
|
+
const state = crypto.randomUUID()
|
|
82
|
+
let redirectUrl: string | null = null
|
|
83
|
+
|
|
84
|
+
page.on('request', (request) => {
|
|
85
|
+
const url = request.url()
|
|
86
|
+
if (url.includes('127.0.0.1:3333') && url.includes('code=')) {
|
|
87
|
+
redirectUrl = url
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const authParams = new URLSearchParams({
|
|
92
|
+
client_id: CLIENT_ID,
|
|
93
|
+
response_type: 'code',
|
|
94
|
+
scope: 'vms.all',
|
|
95
|
+
redirect_uri: REDIRECT_URI,
|
|
96
|
+
state
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
await page.goto(`https://auth.eagleeyenetworks.com/oauth2/authorize?${authParams.toString()}`)
|
|
100
|
+
await page.waitForURL(/.*eagleeyenetworks.com.*/, { timeout: 15000 })
|
|
101
|
+
|
|
102
|
+
const emailInput = page.locator('#authentication--input__email')
|
|
103
|
+
await emailInput.waitFor({ state: 'visible', timeout: 15000 })
|
|
104
|
+
await emailInput.fill(username)
|
|
105
|
+
|
|
106
|
+
await page.getByRole('button', { name: 'Next' }).click()
|
|
107
|
+
|
|
108
|
+
const passwordInput = page.locator('#authentication--input__password')
|
|
109
|
+
await passwordInput.waitFor({ state: 'visible', timeout: 10000 })
|
|
110
|
+
await passwordInput.fill(password)
|
|
111
|
+
|
|
112
|
+
const signInButton = page.locator('#next')
|
|
113
|
+
try {
|
|
114
|
+
await signInButton.click()
|
|
115
|
+
} catch {
|
|
116
|
+
await page.getByRole('button', { name: 'Sign in' }).click()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await page.waitForURL(/127\.0\.0\.1:3333.*code=/, { timeout: 30000 })
|
|
121
|
+
} catch {
|
|
122
|
+
if (!redirectUrl) {
|
|
123
|
+
const currentUrl = page.url()
|
|
124
|
+
if (currentUrl.includes('code=')) {
|
|
125
|
+
redirectUrl = currentUrl
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!redirectUrl) {
|
|
131
|
+
throw new Error('Failed to capture redirect URL with authorization code')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const url = new URL(redirectUrl)
|
|
135
|
+
const code = url.searchParams.get('code')
|
|
136
|
+
const returnedState = url.searchParams.get('state')
|
|
137
|
+
|
|
138
|
+
if (!code || returnedState !== state) {
|
|
139
|
+
throw new Error('Invalid authorization response')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const tokenParams = new URLSearchParams({
|
|
143
|
+
code,
|
|
144
|
+
redirect_uri: REDIRECT_URI
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const tokenResponse = await page.request.post(
|
|
148
|
+
`${PROXY_URL}/proxy/getAccessToken?${tokenParams.toString()}`,
|
|
149
|
+
{
|
|
150
|
+
headers: {
|
|
151
|
+
Accept: 'application/json',
|
|
152
|
+
Origin: 'http://127.0.0.1:3333'
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if (!tokenResponse.ok()) {
|
|
158
|
+
throw new Error(`Token exchange failed: ${tokenResponse.status()}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const tokenData = (await tokenResponse.json()) as TokenResponse
|
|
162
|
+
|
|
163
|
+
let baseUrl: string
|
|
164
|
+
if (typeof tokenData.httpsBaseUrl === 'string') {
|
|
165
|
+
baseUrl = tokenData.httpsBaseUrl
|
|
166
|
+
} else {
|
|
167
|
+
const { hostname, port } = tokenData.httpsBaseUrl
|
|
168
|
+
baseUrl = port ? `https://${hostname}:${port}` : `https://${hostname}`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const authState: AuthState = {
|
|
172
|
+
token: tokenData.accessToken,
|
|
173
|
+
tokenExpiration: Date.now() + tokenData.expiresIn * 1000,
|
|
174
|
+
baseUrl,
|
|
175
|
+
sessionId: tokenData.sessionId
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
fs.writeFileSync(AUTH_CACHE_FILE, JSON.stringify(authState, null, 2), { mode: 0o600 })
|
|
179
|
+
|
|
180
|
+
return authState
|
|
181
|
+
} finally {
|
|
182
|
+
await browser.close()
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
test.describe('vue-media authenticated tests', () => {
|
|
187
|
+
let authState: AuthState
|
|
188
|
+
|
|
189
|
+
test.beforeAll(async () => {
|
|
190
|
+
// Get auth token (from cache or fresh login)
|
|
191
|
+
const cached = loadCachedAuth()
|
|
192
|
+
if (cached) {
|
|
193
|
+
console.log('Using cached auth token')
|
|
194
|
+
authState = cached
|
|
195
|
+
} else {
|
|
196
|
+
console.log('Performing OAuth login...')
|
|
197
|
+
authState = await performLogin()
|
|
198
|
+
console.log('Login successful')
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('authenticated user sees cameras on live page', async ({ page }) => {
|
|
203
|
+
// Set up localStorage with auth state before navigating
|
|
204
|
+
await page.goto('/')
|
|
205
|
+
|
|
206
|
+
// Inject auth state into the app's Pinia store via localStorage
|
|
207
|
+
await page.evaluate(
|
|
208
|
+
({ token, baseUrl, sessionId, tokenExpiration }) => {
|
|
209
|
+
const authData = {
|
|
210
|
+
isAuthenticated: true,
|
|
211
|
+
token,
|
|
212
|
+
tokenExpiration,
|
|
213
|
+
refreshTokenMarker: 'present',
|
|
214
|
+
baseUrl,
|
|
215
|
+
sessionId
|
|
216
|
+
}
|
|
217
|
+
localStorage.setItem('een-auth', JSON.stringify(authData))
|
|
218
|
+
},
|
|
219
|
+
authState
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
// Navigate to live page
|
|
223
|
+
await page.goto('/live')
|
|
224
|
+
|
|
225
|
+
// Should show the live camera view
|
|
226
|
+
await expect(page.getByRole('heading', { name: 'Live Camera View' })).toBeVisible()
|
|
227
|
+
|
|
228
|
+
// Wait for cameras to load (or show no cameras message)
|
|
229
|
+
await page.waitForSelector('[data-testid="camera-select"], .no-cameras', {
|
|
230
|
+
timeout: 30000
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// Either cameras are loaded or we see "no cameras" message
|
|
234
|
+
const cameraSelect = page.getByTestId('camera-select')
|
|
235
|
+
const noCameras = page.locator('.no-cameras')
|
|
236
|
+
|
|
237
|
+
const hasCameras = await cameraSelect.isVisible().catch(() => false)
|
|
238
|
+
const hasNoCameras = await noCameras.isVisible().catch(() => false)
|
|
239
|
+
|
|
240
|
+
expect(hasCameras || hasNoCameras).toBe(true)
|
|
241
|
+
|
|
242
|
+
if (hasCameras) {
|
|
243
|
+
console.log('Cameras found - checking controls')
|
|
244
|
+
|
|
245
|
+
// Verify controls are present
|
|
246
|
+
await expect(page.getByTestId('refresh-button')).toBeVisible()
|
|
247
|
+
await expect(page.getByTestId('auto-refresh-button')).toBeVisible()
|
|
248
|
+
|
|
249
|
+
// Wait for image to load
|
|
250
|
+
await page.waitForSelector('[data-testid="live-image"], .image-loading, .no-image', {
|
|
251
|
+
timeout: 30000
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// Check if we got an image or an error
|
|
255
|
+
const hasImage = await page.getByTestId('live-image').isVisible().catch(() => false)
|
|
256
|
+
if (hasImage) {
|
|
257
|
+
console.log('Live image loaded successfully')
|
|
258
|
+
|
|
259
|
+
// Check timestamp is shown
|
|
260
|
+
const timestamp = page.getByTestId('timestamp')
|
|
261
|
+
await expect(timestamp).toBeVisible()
|
|
262
|
+
} else {
|
|
263
|
+
console.log('No live image available (camera may be offline)')
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
console.log('No cameras in account')
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('authenticated home page shows view live button', async ({ page }) => {
|
|
271
|
+
await page.goto('/')
|
|
272
|
+
|
|
273
|
+
// Inject auth state
|
|
274
|
+
await page.evaluate(
|
|
275
|
+
({ token, baseUrl, sessionId, tokenExpiration }) => {
|
|
276
|
+
const authData = {
|
|
277
|
+
isAuthenticated: true,
|
|
278
|
+
token,
|
|
279
|
+
tokenExpiration,
|
|
280
|
+
refreshTokenMarker: 'present',
|
|
281
|
+
baseUrl,
|
|
282
|
+
sessionId
|
|
283
|
+
}
|
|
284
|
+
localStorage.setItem('een-auth', JSON.stringify(authData))
|
|
285
|
+
},
|
|
286
|
+
authState
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
// Reload to apply auth state
|
|
290
|
+
await page.reload()
|
|
291
|
+
|
|
292
|
+
// Should show authenticated state
|
|
293
|
+
await expect(page.getByTestId('authenticated')).toBeVisible()
|
|
294
|
+
await expect(page.getByTestId('view-live-button')).toBeVisible()
|
|
295
|
+
await expect(page.getByText('You are logged in!')).toBeVisible()
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('refresh button fetches new image', async ({ page }) => {
|
|
299
|
+
await page.goto('/')
|
|
300
|
+
|
|
301
|
+
// Inject auth state
|
|
302
|
+
await page.evaluate(
|
|
303
|
+
({ token, baseUrl, sessionId, tokenExpiration }) => {
|
|
304
|
+
const authData = {
|
|
305
|
+
isAuthenticated: true,
|
|
306
|
+
token,
|
|
307
|
+
tokenExpiration,
|
|
308
|
+
refreshTokenMarker: 'present',
|
|
309
|
+
baseUrl,
|
|
310
|
+
sessionId
|
|
311
|
+
}
|
|
312
|
+
localStorage.setItem('een-auth', JSON.stringify(authData))
|
|
313
|
+
},
|
|
314
|
+
authState
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
await page.goto('/live')
|
|
318
|
+
|
|
319
|
+
// Wait for initial load
|
|
320
|
+
await page.waitForSelector('[data-testid="camera-select"], .no-cameras', {
|
|
321
|
+
timeout: 30000
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
const hasCameras = await page.getByTestId('camera-select').isVisible().catch(() => false)
|
|
325
|
+
|
|
326
|
+
if (hasCameras) {
|
|
327
|
+
// Wait for initial image
|
|
328
|
+
await page.waitForSelector('[data-testid="live-image"], .no-image', {
|
|
329
|
+
timeout: 30000
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
// Click refresh
|
|
333
|
+
await page.getByTestId('refresh-button').click()
|
|
334
|
+
|
|
335
|
+
// Button should show loading state briefly
|
|
336
|
+
// Just verify the click works without errors
|
|
337
|
+
await expect(page.getByTestId('refresh-button')).toBeVisible()
|
|
338
|
+
|
|
339
|
+
console.log('Refresh button clicked successfully')
|
|
340
|
+
} else {
|
|
341
|
+
console.log('No cameras to test refresh with')
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>EEN Media Example</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|