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.
- package/.claude/agents/een-jobs-agent.md +676 -0
- package/CHANGELOG.md +7 -8
- package/dist/index.cjs +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1172 -28
- package/dist/index.js +796 -333
- package/dist/index.js.map +1 -1
- package/docs/AI-CONTEXT.md +22 -1
- package/docs/ai-reference/AI-AUTH.md +1 -1
- package/docs/ai-reference/AI-AUTOMATIONS.md +1 -1
- package/docs/ai-reference/AI-DEVICES.md +1 -1
- package/docs/ai-reference/AI-EVENTS.md +1 -1
- package/docs/ai-reference/AI-GROUPING.md +1 -1
- package/docs/ai-reference/AI-JOBS.md +1084 -0
- package/docs/ai-reference/AI-MEDIA.md +1 -1
- package/docs/ai-reference/AI-SETUP.md +1 -1
- package/docs/ai-reference/AI-USERS.md +1 -1
- package/examples/vue-jobs/.env.example +11 -0
- package/examples/vue-jobs/README.md +245 -0
- package/examples/vue-jobs/e2e/app.spec.ts +79 -0
- package/examples/vue-jobs/e2e/auth.spec.ts +382 -0
- package/examples/vue-jobs/e2e/delete-features.spec.ts +564 -0
- package/examples/vue-jobs/e2e/timelapse.spec.ts +361 -0
- package/examples/vue-jobs/index.html +13 -0
- package/examples/vue-jobs/package-lock.json +1722 -0
- package/examples/vue-jobs/package.json +28 -0
- package/examples/vue-jobs/playwright.config.ts +47 -0
- package/examples/vue-jobs/src/App.vue +154 -0
- package/examples/vue-jobs/src/main.ts +25 -0
- package/examples/vue-jobs/src/router/index.ts +82 -0
- package/examples/vue-jobs/src/views/Callback.vue +76 -0
- package/examples/vue-jobs/src/views/CreateExport.vue +284 -0
- package/examples/vue-jobs/src/views/Files.vue +424 -0
- package/examples/vue-jobs/src/views/Home.vue +195 -0
- package/examples/vue-jobs/src/views/JobDetail.vue +392 -0
- package/examples/vue-jobs/src/views/Jobs.vue +297 -0
- package/examples/vue-jobs/src/views/Login.vue +33 -0
- package/examples/vue-jobs/src/views/Logout.vue +59 -0
- package/examples/vue-jobs/src/vite-env.d.ts +1 -0
- package/examples/vue-jobs/tsconfig.json +25 -0
- package/examples/vue-jobs/vite.config.ts +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { test, expect, Page } from '@playwright/test'
|
|
2
|
+
import { baseURL } from '../playwright.config'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* E2E tests for TimeLapse export functionality
|
|
6
|
+
*
|
|
7
|
+
* Tests the timeLapse export flow which requires playbackMultiplier:
|
|
8
|
+
* 1. Login and navigate to Create Export
|
|
9
|
+
* 2. Select timeLapse type and verify playbackMultiplier field appears
|
|
10
|
+
* 3. Create a timeLapse export job
|
|
11
|
+
* 4. Verify job is created and tracked
|
|
12
|
+
*
|
|
13
|
+
* Required environment variables:
|
|
14
|
+
* - VITE_PROXY_URL: OAuth proxy URL
|
|
15
|
+
* - VITE_EEN_CLIENT_ID: EEN OAuth client ID
|
|
16
|
+
* - TEST_USER: Test user email
|
|
17
|
+
* - TEST_PASSWORD: Test user password
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const TIMEOUTS = {
|
|
21
|
+
OAUTH_REDIRECT: 30000,
|
|
22
|
+
ELEMENT_VISIBLE: 15000,
|
|
23
|
+
PASSWORD_VISIBLE: 10000,
|
|
24
|
+
AUTH_COMPLETE: 30000,
|
|
25
|
+
UI_UPDATE: 10000,
|
|
26
|
+
PROXY_CHECK: 5000,
|
|
27
|
+
JOB_CREATION: 30000
|
|
28
|
+
} as const
|
|
29
|
+
|
|
30
|
+
const TEST_USER = process.env.TEST_USER
|
|
31
|
+
const TEST_PASSWORD = process.env.TEST_PASSWORD
|
|
32
|
+
const PROXY_URL = process.env.VITE_PROXY_URL
|
|
33
|
+
|
|
34
|
+
async function isProxyAccessible(): Promise<boolean> {
|
|
35
|
+
if (!PROXY_URL) return false
|
|
36
|
+
const controller = new AbortController()
|
|
37
|
+
const timeoutId = setTimeout(() => controller.abort(), TIMEOUTS.PROXY_CHECK)
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch(PROXY_URL, {
|
|
41
|
+
method: 'HEAD',
|
|
42
|
+
signal: controller.signal
|
|
43
|
+
})
|
|
44
|
+
return response.ok || response.status === 404
|
|
45
|
+
} catch {
|
|
46
|
+
return false
|
|
47
|
+
} finally {
|
|
48
|
+
clearTimeout(timeoutId)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function performLogin(page: Page, username: string, password: string): Promise<void> {
|
|
53
|
+
await page.goto('/')
|
|
54
|
+
|
|
55
|
+
await Promise.all([
|
|
56
|
+
page.waitForURL(/.*eagleeyenetworks.com.*/, { timeout: TIMEOUTS.OAUTH_REDIRECT }),
|
|
57
|
+
page.click('[data-testid="login-button"]')
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
const emailInput = page.locator('#authentication--input__email')
|
|
61
|
+
await emailInput.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE })
|
|
62
|
+
await emailInput.fill(username)
|
|
63
|
+
|
|
64
|
+
await page.getByRole('button', { name: 'Next' }).click()
|
|
65
|
+
|
|
66
|
+
const passwordInput = page.locator('#authentication--input__password')
|
|
67
|
+
await passwordInput.waitFor({ state: 'visible', timeout: TIMEOUTS.PASSWORD_VISIBLE })
|
|
68
|
+
await passwordInput.fill(password)
|
|
69
|
+
|
|
70
|
+
await page.locator('#next, button:has-text("Sign in")').first().click()
|
|
71
|
+
|
|
72
|
+
const baseURLPattern = new RegExp(baseURL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
73
|
+
await page.waitForURL(baseURLPattern, { timeout: TIMEOUTS.AUTH_COMPLETE })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test.describe('TimeLapse Export', () => {
|
|
77
|
+
test.beforeAll(async () => {
|
|
78
|
+
// Check prerequisites
|
|
79
|
+
if (!TEST_USER || !TEST_PASSWORD) {
|
|
80
|
+
console.log('Skipping: TEST_USER and TEST_PASSWORD required')
|
|
81
|
+
test.skip()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const proxyAccessible = await isProxyAccessible()
|
|
85
|
+
if (!proxyAccessible) {
|
|
86
|
+
console.log('Skipping: OAuth proxy not accessible at', PROXY_URL)
|
|
87
|
+
test.skip()
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('playbackMultiplier field appears when timeLapse is selected', async ({ page }) => {
|
|
92
|
+
if (!TEST_USER || !TEST_PASSWORD) {
|
|
93
|
+
test.skip()
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Login
|
|
98
|
+
await performLogin(page, TEST_USER, TEST_PASSWORD)
|
|
99
|
+
|
|
100
|
+
// Wait for authenticated state
|
|
101
|
+
await page.waitForSelector('.authenticated, .user-info', { timeout: TIMEOUTS.UI_UPDATE })
|
|
102
|
+
|
|
103
|
+
// Navigate to Create Export page
|
|
104
|
+
await page.click('a[href="/create-export"], button:has-text("Create Export")')
|
|
105
|
+
await page.waitForURL(/.*create-export.*/, { timeout: TIMEOUTS.UI_UPDATE })
|
|
106
|
+
|
|
107
|
+
// Wait for cameras to load - check that camera select has options
|
|
108
|
+
const cameraSelect = page.locator('select#camera')
|
|
109
|
+
await expect(cameraSelect).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
110
|
+
|
|
111
|
+
// Wait for at least one camera option to be available
|
|
112
|
+
const cameraOptions = cameraSelect.locator('option:not([value=""])')
|
|
113
|
+
await expect(cameraOptions.first()).toBeAttached({ timeout: TIMEOUTS.UI_UPDATE })
|
|
114
|
+
|
|
115
|
+
// Verify playback multiplier is NOT visible for video type (default)
|
|
116
|
+
const playbackMultiplierSelect = page.locator('select#playbackMultiplier')
|
|
117
|
+
await expect(playbackMultiplierSelect).not.toBeVisible()
|
|
118
|
+
|
|
119
|
+
// Select timeLapse export type
|
|
120
|
+
await page.selectOption('select#exportType', 'timeLapse')
|
|
121
|
+
|
|
122
|
+
// Verify playback multiplier IS visible for timeLapse
|
|
123
|
+
await expect(playbackMultiplierSelect).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
124
|
+
|
|
125
|
+
// Verify it has options
|
|
126
|
+
const options = await playbackMultiplierSelect.locator('option').count()
|
|
127
|
+
expect(options).toBeGreaterThan(0)
|
|
128
|
+
console.log('TimeLapse playback multiplier field has', options, 'options')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('playbackMultiplier field appears when bundle is selected', async ({ page }) => {
|
|
132
|
+
if (!TEST_USER || !TEST_PASSWORD) {
|
|
133
|
+
test.skip()
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Login
|
|
138
|
+
await performLogin(page, TEST_USER, TEST_PASSWORD)
|
|
139
|
+
await page.waitForSelector('.authenticated, .user-info', { timeout: TIMEOUTS.UI_UPDATE })
|
|
140
|
+
|
|
141
|
+
// Navigate to Create Export page
|
|
142
|
+
await page.click('a[href="/create-export"], button:has-text("Create Export")')
|
|
143
|
+
await page.waitForURL(/.*create-export.*/, { timeout: TIMEOUTS.UI_UPDATE })
|
|
144
|
+
|
|
145
|
+
// Wait for cameras to load
|
|
146
|
+
const cameraSelect = page.locator('select#camera')
|
|
147
|
+
await expect(cameraSelect).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
148
|
+
const cameraOptions = cameraSelect.locator('option:not([value=""])')
|
|
149
|
+
await expect(cameraOptions.first()).toBeAttached({ timeout: TIMEOUTS.UI_UPDATE })
|
|
150
|
+
|
|
151
|
+
// Select bundle export type
|
|
152
|
+
await page.selectOption('select#exportType', 'bundle')
|
|
153
|
+
|
|
154
|
+
// Verify playback multiplier IS visible for bundle
|
|
155
|
+
const playbackMultiplierSelect = page.locator('select#playbackMultiplier')
|
|
156
|
+
await expect(playbackMultiplierSelect).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('can create timeLapse export job', async ({ page }) => {
|
|
160
|
+
if (!TEST_USER || !TEST_PASSWORD) {
|
|
161
|
+
test.skip()
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Login
|
|
166
|
+
await performLogin(page, TEST_USER, TEST_PASSWORD)
|
|
167
|
+
await page.waitForSelector('.authenticated, .user-info', { timeout: TIMEOUTS.UI_UPDATE })
|
|
168
|
+
|
|
169
|
+
// Navigate to Create Export page
|
|
170
|
+
await page.click('a[href="/create-export"], button:has-text("Create Export")')
|
|
171
|
+
await page.waitForURL(/.*create-export.*/, { timeout: TIMEOUTS.UI_UPDATE })
|
|
172
|
+
|
|
173
|
+
// Wait for cameras to load
|
|
174
|
+
const cameraSelect = page.locator('select#camera')
|
|
175
|
+
await expect(cameraSelect).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
176
|
+
|
|
177
|
+
// Wait a moment for options to populate
|
|
178
|
+
await page.waitForTimeout(1000)
|
|
179
|
+
|
|
180
|
+
const cameraOptions = cameraSelect.locator('option:not([value=""])')
|
|
181
|
+
const cameraCount = await cameraOptions.count()
|
|
182
|
+
|
|
183
|
+
if (cameraCount === 0) {
|
|
184
|
+
console.log('No cameras available - skipping timeLapse export test')
|
|
185
|
+
test.skip()
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Select first camera
|
|
190
|
+
const firstCameraValue = await cameraOptions.first().getAttribute('value')
|
|
191
|
+
if (firstCameraValue) {
|
|
192
|
+
await page.selectOption('select#camera', firstCameraValue)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Select timeLapse export type
|
|
196
|
+
await page.selectOption('select#exportType', 'timeLapse')
|
|
197
|
+
|
|
198
|
+
// Verify playback multiplier appears
|
|
199
|
+
const playbackMultiplierSelect = page.locator('select#playbackMultiplier')
|
|
200
|
+
await expect(playbackMultiplierSelect).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
201
|
+
|
|
202
|
+
// Select a playback multiplier (10x)
|
|
203
|
+
await page.selectOption('select#playbackMultiplier', '10')
|
|
204
|
+
|
|
205
|
+
// Select shortest duration (5 minutes)
|
|
206
|
+
await page.selectOption('select#duration', '5')
|
|
207
|
+
|
|
208
|
+
// Enter export name
|
|
209
|
+
await page.fill('input#name', 'E2E TimeLapse Test Export')
|
|
210
|
+
|
|
211
|
+
// Submit the form
|
|
212
|
+
await page.click('button[type="submit"]')
|
|
213
|
+
|
|
214
|
+
// Wait for either success or error
|
|
215
|
+
const resultSelector = '.success, .error'
|
|
216
|
+
await page.waitForSelector(resultSelector, { timeout: TIMEOUTS.JOB_CREATION })
|
|
217
|
+
|
|
218
|
+
// Check result
|
|
219
|
+
const successElement = page.locator('.success')
|
|
220
|
+
const errorElement = page.locator('.error')
|
|
221
|
+
|
|
222
|
+
if (await successElement.isVisible()) {
|
|
223
|
+
const successText = await successElement.textContent()
|
|
224
|
+
console.log('TimeLapse export job created:', successText)
|
|
225
|
+
expect(successText).toContain('Export job created successfully')
|
|
226
|
+
|
|
227
|
+
// Should redirect to job detail page
|
|
228
|
+
await page.waitForURL(/.*jobs\/.*/, { timeout: TIMEOUTS.UI_UPDATE })
|
|
229
|
+
console.log('Redirected to job detail page:', page.url())
|
|
230
|
+
} else if (await errorElement.isVisible()) {
|
|
231
|
+
const errorText = await errorElement.textContent()
|
|
232
|
+
console.log('TimeLapse export creation failed:', errorText)
|
|
233
|
+
|
|
234
|
+
// Some errors are acceptable (no video in time range, etc.)
|
|
235
|
+
// Just log and don't fail the test for expected errors
|
|
236
|
+
if (errorText?.includes('no video') || errorText?.includes('No video')) {
|
|
237
|
+
console.log('Expected error: No video available in time range')
|
|
238
|
+
} else {
|
|
239
|
+
// Unexpected error - fail with details
|
|
240
|
+
expect.soft(errorText).not.toContain('playbackMultiplier')
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('can create bundle export job', async ({ page }) => {
|
|
246
|
+
if (!TEST_USER || !TEST_PASSWORD) {
|
|
247
|
+
test.skip()
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Login
|
|
252
|
+
await performLogin(page, TEST_USER, TEST_PASSWORD)
|
|
253
|
+
await page.waitForSelector('.authenticated, .user-info', { timeout: TIMEOUTS.UI_UPDATE })
|
|
254
|
+
|
|
255
|
+
// Navigate to Create Export page
|
|
256
|
+
await page.click('a[href="/create-export"], button:has-text("Create Export")')
|
|
257
|
+
await page.waitForURL(/.*create-export.*/, { timeout: TIMEOUTS.UI_UPDATE })
|
|
258
|
+
|
|
259
|
+
// Wait for cameras to load
|
|
260
|
+
const cameraSelect = page.locator('select#camera')
|
|
261
|
+
await expect(cameraSelect).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
262
|
+
|
|
263
|
+
// Wait a moment for options to populate
|
|
264
|
+
await page.waitForTimeout(1000)
|
|
265
|
+
|
|
266
|
+
const cameraOptions = cameraSelect.locator('option:not([value=""])')
|
|
267
|
+
const cameraCount = await cameraOptions.count()
|
|
268
|
+
|
|
269
|
+
if (cameraCount === 0) {
|
|
270
|
+
console.log('No cameras available - skipping bundle export test')
|
|
271
|
+
test.skip()
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Select first camera
|
|
276
|
+
const firstCameraValue = await cameraOptions.first().getAttribute('value')
|
|
277
|
+
if (firstCameraValue) {
|
|
278
|
+
await page.selectOption('select#camera', firstCameraValue)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Select bundle export type
|
|
282
|
+
await page.selectOption('select#exportType', 'bundle')
|
|
283
|
+
|
|
284
|
+
// Verify playback multiplier appears (required for bundle)
|
|
285
|
+
const playbackMultiplierSelect = page.locator('select#playbackMultiplier')
|
|
286
|
+
await expect(playbackMultiplierSelect).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
287
|
+
|
|
288
|
+
// Select a playback multiplier (5x)
|
|
289
|
+
await page.selectOption('select#playbackMultiplier', '5')
|
|
290
|
+
|
|
291
|
+
// Select shortest duration (5 minutes)
|
|
292
|
+
await page.selectOption('select#duration', '5')
|
|
293
|
+
|
|
294
|
+
// Enter export name
|
|
295
|
+
await page.fill('input#name', 'E2E Bundle Test Export')
|
|
296
|
+
|
|
297
|
+
// Submit the form
|
|
298
|
+
await page.click('button[type="submit"]')
|
|
299
|
+
|
|
300
|
+
// Wait for either success or error
|
|
301
|
+
const resultSelector = '.success, .error'
|
|
302
|
+
await page.waitForSelector(resultSelector, { timeout: TIMEOUTS.JOB_CREATION })
|
|
303
|
+
|
|
304
|
+
// Check result
|
|
305
|
+
const successElement = page.locator('.success')
|
|
306
|
+
const errorElement = page.locator('.error')
|
|
307
|
+
|
|
308
|
+
if (await successElement.isVisible()) {
|
|
309
|
+
const successText = await successElement.textContent()
|
|
310
|
+
console.log('Bundle export job created:', successText)
|
|
311
|
+
expect(successText).toContain('Export job created successfully')
|
|
312
|
+
|
|
313
|
+
// Should redirect to job detail page
|
|
314
|
+
await page.waitForURL(/.*jobs\/.*/, { timeout: TIMEOUTS.UI_UPDATE })
|
|
315
|
+
console.log('Redirected to job detail page:', page.url())
|
|
316
|
+
} else if (await errorElement.isVisible()) {
|
|
317
|
+
const errorText = await errorElement.textContent()
|
|
318
|
+
console.log('Bundle export creation failed:', errorText)
|
|
319
|
+
|
|
320
|
+
// Some errors are acceptable (no video in time range, etc.)
|
|
321
|
+
if (errorText?.includes('no video') || errorText?.includes('No video')) {
|
|
322
|
+
console.log('Expected error: No video available in time range')
|
|
323
|
+
} else {
|
|
324
|
+
// Unexpected error - fail with details
|
|
325
|
+
expect.soft(errorText).not.toContain('playbackMultiplier')
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('video export does not show playbackMultiplier field', async ({ page }) => {
|
|
331
|
+
if (!TEST_USER || !TEST_PASSWORD) {
|
|
332
|
+
test.skip()
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Login
|
|
337
|
+
await performLogin(page, TEST_USER, TEST_PASSWORD)
|
|
338
|
+
await page.waitForSelector('.authenticated, .user-info', { timeout: TIMEOUTS.UI_UPDATE })
|
|
339
|
+
|
|
340
|
+
// Navigate to Create Export page
|
|
341
|
+
await page.click('a[href="/create-export"], button:has-text("Create Export")')
|
|
342
|
+
await page.waitForURL(/.*create-export.*/, { timeout: TIMEOUTS.UI_UPDATE })
|
|
343
|
+
|
|
344
|
+
// Wait for cameras to load
|
|
345
|
+
const cameraSelect = page.locator('select#camera')
|
|
346
|
+
await expect(cameraSelect).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
347
|
+
const cameraOptions = cameraSelect.locator('option:not([value=""])')
|
|
348
|
+
await expect(cameraOptions.first()).toBeAttached({ timeout: TIMEOUTS.UI_UPDATE })
|
|
349
|
+
|
|
350
|
+
// Video is default, verify playback multiplier is NOT visible
|
|
351
|
+
const playbackMultiplierSelect = page.locator('select#playbackMultiplier')
|
|
352
|
+
await expect(playbackMultiplierSelect).not.toBeVisible()
|
|
353
|
+
|
|
354
|
+
// Explicitly select video type
|
|
355
|
+
await page.selectOption('select#exportType', 'video')
|
|
356
|
+
|
|
357
|
+
// Still should not be visible
|
|
358
|
+
await expect(playbackMultiplierSelect).not.toBeVisible()
|
|
359
|
+
console.log('Verified: playbackMultiplier not shown for video export type')
|
|
360
|
+
})
|
|
361
|
+
})
|
|
@@ -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 Jobs Example</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|