Archipelago — open-source initial import
@@ -0,0 +1,66 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
|
||||
const APP_ID = process.env.ARCHY_APP_ID ?? 'lnd'
|
||||
const APP_TITLE = process.env.ARCHY_APP_TITLE ?? APP_ID
|
||||
const APP_CARD_TITLE = process.env.ARCHY_APP_CARD_TITLE ?? APP_TITLE
|
||||
const EXPECTED_URL = process.env.ARCHY_EXPECTED_LAUNCH_URL
|
||||
const EXPECTED_URL_PATTERN = process.env.ARCHY_EXPECTED_LAUNCH_URL_PATTERN
|
||||
const EXPECTED_BODY_PATTERN = process.env.ARCHY_EXPECTED_BODY_PATTERN ?? 'Connect Your Wallet|lndconnect|REST|gRPC'
|
||||
const EXPECTED_MODE = process.env.ARCHY_EXPECTED_LAUNCH_MODE ?? 'popup'
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto('/login', { waitUntil: 'domcontentloaded' })
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
})
|
||||
await page.goto('/login', { waitUntil: 'networkidle' })
|
||||
|
||||
const passwordInput = page.locator('input[type="password"]').first()
|
||||
await passwordInput.waitFor({ timeout: 15_000 })
|
||||
await passwordInput.fill(PASSWORD)
|
||||
await page.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]').first().click()
|
||||
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
|
||||
}
|
||||
|
||||
test('installed app launch opens reachable app URL', async ({ page, context, baseURL }) => {
|
||||
test.skip(!EXPECTED_URL, 'Set ARCHY_EXPECTED_LAUNCH_URL for launch qualification')
|
||||
|
||||
await login(page)
|
||||
await page.goto('/dashboard/apps', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
const appCard = page.locator('[data-controller-container]', {
|
||||
has: page.getByRole('heading', { name: APP_CARD_TITLE, exact: true }),
|
||||
}).first()
|
||||
await appCard.waitFor({ timeout: 30_000 })
|
||||
const launchButton = appCard.locator('[data-controller-launch-btn], button:has-text("Launch")').first()
|
||||
await launchButton.waitFor({ timeout: 20_000 })
|
||||
|
||||
if (EXPECTED_MODE === 'panel') {
|
||||
await launchButton.click()
|
||||
const expected = new URL(EXPECTED_URL!, baseURL)
|
||||
const frameSelector = `iframe[src^="${expected.toString().replace(/\/$/, '')}"]`
|
||||
await expect(page.locator(frameSelector).first()).toBeVisible({ timeout: 20_000 })
|
||||
const frame = page.frameLocator(frameSelector).first()
|
||||
await expect(frame.locator('body')).toContainText(new RegExp(EXPECTED_BODY_PATTERN, 'i'), { timeout: 30_000 })
|
||||
return
|
||||
}
|
||||
|
||||
const popupPromise = context.waitForEvent('page', { timeout: 15_000 })
|
||||
await launchButton.click()
|
||||
const popup = await popupPromise
|
||||
await popup.waitForLoadState('domcontentloaded', { timeout: 20_000 })
|
||||
|
||||
assertLaunchUrl(popup.url(), baseURL)
|
||||
await expect(popup.locator('body')).toContainText(new RegExp(EXPECTED_BODY_PATTERN, 'i'), { timeout: 20_000 })
|
||||
})
|
||||
|
||||
function assertLaunchUrl(actual: string, baseURL: string | undefined) {
|
||||
if (EXPECTED_URL_PATTERN) {
|
||||
expect(actual).toMatch(new RegExp(EXPECTED_URL_PATTERN))
|
||||
} else {
|
||||
const expected = new URL(EXPECTED_URL!, baseURL)
|
||||
expect(actual).toBe(expected.toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* End-to-end verification of the FIRST-VISIT cinematic:
|
||||
* splash (tap logo → alien typing → Welcome Noderunner + speech + synthwave
|
||||
* → Archipelago logo) → onboarding intro → login (video background, finale
|
||||
* armed) → dashboard full reveal (background zoom + interface assembly +
|
||||
* oomph) → welcome typing — and that a SECOND login stays deliberately
|
||||
* low-key.
|
||||
*
|
||||
* Run against the local demo stack:
|
||||
* DEMO=1 node mock-backend.js (port 5959)
|
||||
* VITE_DEMO=1 npx vite --port 8100
|
||||
* ARCHY_BASE_URL=http://localhost:8100 npx playwright test e2e/intro-experience.spec.ts
|
||||
*
|
||||
* Audio can't be heard headless, so every HTMLMediaElement.play() and
|
||||
* WebAudio oscillator start is recorded into window.__audioLog via an init
|
||||
* script — the assertions check the right cues fired at the right phases.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
// Real Chrome (new headless): the bundled headless-shell has no working video
|
||||
// pipeline (0 fps, ~80% dropped frames), which makes every media assertion
|
||||
// meaningless there. Requires Google Chrome installed.
|
||||
test.use({ channel: 'chrome' })
|
||||
|
||||
const BASE = process.env.ARCHY_BASE_URL ?? 'http://localhost:8100'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__audioLog: string[]
|
||||
__videoStats: { stalls: number; waiting: number; dropped: number; total: number; readyStateAtPlay: number }
|
||||
__revealSeen: { zoom?: boolean; glass?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
async function instrumentAudioAndVideo(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
window.__audioLog = []
|
||||
window.__videoStats = { stalls: 0, waiting: 0, dropped: 0, total: 0, readyStateAtPlay: -1 }
|
||||
|
||||
// The dashboard reveal classes live only ~8s; a slow run can burn that
|
||||
// window between two sequential expect() polls. Record their appearance
|
||||
// the moment it happens instead, and let the test assert on the record.
|
||||
window.__revealSeen = {}
|
||||
new MutationObserver(() => {
|
||||
if (!window.__revealSeen.zoom && document.querySelector('.zoom-reveal-bg')) window.__revealSeen.zoom = true
|
||||
if (!window.__revealSeen.glass && document.querySelector('.glass-throw-active')) window.__revealSeen.glass = true
|
||||
}).observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
|
||||
|
||||
const origPlay = HTMLMediaElement.prototype.play
|
||||
HTMLMediaElement.prototype.play = function (...args) {
|
||||
const src = (this.currentSrc || this.src || (this.querySelector?.('source') as HTMLSourceElement | null)?.src || 'unknown')
|
||||
window.__audioLog.push(`media-play:${src.split('/').pop()}`)
|
||||
if (this.tagName === 'VIDEO') {
|
||||
const v = this as HTMLVideoElement
|
||||
if (window.__videoStats.readyStateAtPlay === -1) window.__videoStats.readyStateAtPlay = v.readyState
|
||||
v.addEventListener('stalled', () => { window.__videoStats.stalls++ })
|
||||
v.addEventListener('waiting', () => { window.__videoStats.waiting++ })
|
||||
}
|
||||
return origPlay.apply(this, args)
|
||||
}
|
||||
|
||||
// WebAudio: oscillator/buffer starts = synth pops, oomph layers, synthwave.
|
||||
const OrigOsc = OscillatorNode.prototype.start
|
||||
OscillatorNode.prototype.start = function (...args) {
|
||||
window.__audioLog.push('osc-start')
|
||||
return OrigOsc.apply(this, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function freshVisit(page: Page) {
|
||||
await page.goto(BASE + '/')
|
||||
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear() })
|
||||
await page.goto(BASE + '/')
|
||||
}
|
||||
|
||||
/** Walk the full splash from tap-to-start through completion (real time). */
|
||||
async function runSplash(page: Page, { skip }: { skip: boolean }) {
|
||||
// Phase 1: tap-to-start — "Enter to Exit" + logo (the overlay animates away
|
||||
// on click, so don't wait for post-click actionability)
|
||||
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
|
||||
await page.locator('.tap-to-start-logo').click({ noWaitAfter: true, force: true })
|
||||
|
||||
// Phase 2: alien typing begins (first line types out)
|
||||
await expect(page.getByText('In the future there will be 3 types', { exact: false }))
|
||||
.toBeVisible({ timeout: 10_000 })
|
||||
|
||||
if (skip) {
|
||||
await page.getByRole('button', { name: 'Skip Intro' }).click({ noWaitAfter: true })
|
||||
} else {
|
||||
// Let all four lines type out for real (~20s)
|
||||
await expect(page.getByText('And Noderunners...', { exact: false })).toBeVisible({ timeout: 45_000 })
|
||||
}
|
||||
|
||||
// Phase 3+4 (Welcome Noderunner → logo) mount the background video. The
|
||||
// text is only on screen ~6s, so verify the phases via durable signals:
|
||||
// the video's live health here, and the audio log (speech/song) after.
|
||||
await page.waitForFunction(() => !!document.querySelector('video'), { timeout: 30_000 })
|
||||
// Wait for actual smooth playback (cold-cache buffering right after mount
|
||||
// is masked by the design's 0.3-opacity fade — smoothness is what matters).
|
||||
await page.waitForFunction(() => {
|
||||
const v = document.querySelector('video')
|
||||
return !!v && v.readyState >= 3 && v.currentTime > 0.3
|
||||
}, { timeout: 20_000 })
|
||||
const s1 = await page.evaluate(() => {
|
||||
const v = document.querySelector('video')!
|
||||
const q = v.getVideoPlaybackQuality?.()
|
||||
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
|
||||
})
|
||||
await page.waitForTimeout(2_000)
|
||||
const s2 = await page.evaluate(() => {
|
||||
const v = document.querySelector('video')
|
||||
if (!v) return null
|
||||
const q = v.getVideoPlaybackQuality?.()
|
||||
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
|
||||
})
|
||||
expect(s2).not.toBeNull()
|
||||
// The 8.1s video loops; a loop wrap makes (t - t1) negative — treat as full progress.
|
||||
const progressed = s2!.t >= s1.t ? s2!.t - s1.t : s2!.t + (8.1 - s1.t)
|
||||
expect(progressed).toBeGreaterThan(1.2) // ≥1.2s progress in 2s wall = playing smoothly
|
||||
// Steady-state frame drops over the sample window (startup catch-up excluded).
|
||||
const dTotal = s2!.total - s1.total
|
||||
const dDropped = s2!.dropped - s1.dropped
|
||||
if (dTotal > 30) expect(dDropped / dTotal).toBeLessThan(0.2)
|
||||
|
||||
// Splash completes → demo routes to the onboarding intro
|
||||
await page.waitForURL('**/onboarding/intro', { timeout: 60_000 })
|
||||
}
|
||||
|
||||
async function enterDemoAndLogin(page: Page) {
|
||||
// The CTA unmounts mid-click when the router transitions away — dispatch
|
||||
// once, swallow the detach retry, and trust the URL change instead.
|
||||
const cta = page.getByRole('button', { name: /Enter the demo/ })
|
||||
await cta.waitFor({ timeout: 15_000 })
|
||||
await Promise.all([
|
||||
page.waitForURL('**/login', { timeout: 15_000 }),
|
||||
cta.click({ noWaitAfter: true }).catch(() => {}),
|
||||
])
|
||||
// Demo prefills the password; the finale flag must be armed at this point.
|
||||
expect(await page.evaluate(() => sessionStorage.getItem('archy_onboarding_finale'))).toBe('1')
|
||||
const loginBtn = page.getByRole('button', { name: /log ?in/i })
|
||||
await loginBtn.waitFor({ timeout: 10_000 })
|
||||
await Promise.all([
|
||||
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
|
||||
loginBtn.click({ noWaitAfter: true }).catch(() => {}),
|
||||
])
|
||||
}
|
||||
|
||||
test.describe('first-visit cinematic', () => {
|
||||
test('full no-skip run: sounds, video health, zoom reveal, welcome typing', async ({ page }) => {
|
||||
test.setTimeout(240_000)
|
||||
await instrumentAudioAndVideo(page)
|
||||
await freshVisit(page)
|
||||
|
||||
await runSplash(page, { skip: false })
|
||||
|
||||
// Cinematic audio fired: intro typing loop, the Welcome Noderunner speech
|
||||
// and the synthwave bed (cosmic-updrift) — the exact regression reported.
|
||||
const log = await page.evaluate(() => window.__audioLog.join('|'))
|
||||
expect(log).toContain('welcome-noderunner.mp3')
|
||||
expect(log).toContain('cosmic-updrift.mp3')
|
||||
|
||||
await enterDemoAndLogin(page)
|
||||
|
||||
// FULL first-entry reveal: big background zoom + glass assembly classes
|
||||
// (recorded by the init-script observer the instant they appear — the
|
||||
// classes only live ~8s and sequential polling can miss the window).
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__revealSeen), { timeout: 15_000 })
|
||||
.toMatchObject({ zoom: true, glass: true })
|
||||
|
||||
// Welcome typing kicks in ~4s into the reveal and animates the home cards.
|
||||
await expect(page.locator('.home-card-animate').first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// The reveal runs 8s, then the zoom layer class clears.
|
||||
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0, { timeout: 20_000 })
|
||||
|
||||
// The dashboard oomph is WebAudio oscillators — at least the login pop +
|
||||
// oomph layers must have started after the splash's own sounds.
|
||||
const oscCount = await page.evaluate(() => window.__audioLog.filter(e => e === 'osc-start').length)
|
||||
expect(oscCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('skip-intro run still gets speech, song and the dashboard reveal', async ({ page }) => {
|
||||
test.setTimeout(180_000)
|
||||
await instrumentAudioAndVideo(page)
|
||||
await freshVisit(page)
|
||||
|
||||
await runSplash(page, { skip: true })
|
||||
|
||||
const log = await page.evaluate(() => window.__audioLog.join('|'))
|
||||
expect(log).toContain('welcome-noderunner.mp3')
|
||||
expect(log).toContain('cosmic-updrift.mp3')
|
||||
|
||||
await enterDemoAndLogin(page)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('second login is deliberately low-key (no zoom reveal)', async ({ page }) => {
|
||||
test.setTimeout(180_000)
|
||||
await instrumentAudioAndVideo(page)
|
||||
await freshVisit(page)
|
||||
await runSplash(page, { skip: true })
|
||||
await enterDemoAndLogin(page)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
|
||||
.toBe(true)
|
||||
|
||||
// End the session the way logout does (auth token only — the intro/login
|
||||
// flags survive) and revisit login directly. An authenticated /login visit
|
||||
// would just bounce back to the dashboard via the router guard.
|
||||
await page.evaluate(() => localStorage.removeItem('neode-auth'))
|
||||
// Direct /login navigation (no splash — not a root boot) and re-login.
|
||||
await page.goto(BASE + '/login')
|
||||
await page.locator('#login-password').waitFor({ timeout: 15_000 })
|
||||
// First login happened → static rotated background, not the video.
|
||||
await expect(page.locator('.bg-login-static')).toBeVisible({ timeout: 10_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('neode_first_login_done'))).toBe('1')
|
||||
|
||||
const reloginBtn = page.getByRole('button', { name: /log ?in/i })
|
||||
await Promise.all([
|
||||
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
|
||||
reloginBtn.click({ noWaitAfter: true }).catch(() => {}),
|
||||
])
|
||||
// Low-key entrance: NO zoom reveal on a regular re-login. The observer
|
||||
// reset with the /login reload, so it would have caught even a transient
|
||||
// reveal since then.
|
||||
await page.waitForTimeout(1500)
|
||||
expect(await page.evaluate(() => window.__revealSeen.zoom ?? false)).toBe(false)
|
||||
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('demo replays the cinematic on a fresh boot at root', async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
await freshVisit(page)
|
||||
await runSplash(page, { skip: true })
|
||||
await enterDemoAndLogin(page)
|
||||
|
||||
// Reload at root = a fresh boot → the splash must return even though
|
||||
// neode_intro_seen is now set.
|
||||
await page.goto(BASE + '/')
|
||||
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('video is warmed before the splash needs it', async ({ page }) => {
|
||||
await freshVisit(page)
|
||||
// The warm-up is a detached <video preload=auto> kept on window (Chromium
|
||||
// has no <link rel=preload as=video>). Give it a moment to buffer.
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => {
|
||||
const w = (window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm
|
||||
return w ? { src: w.src, readyState: w.readyState } : null
|
||||
}), { timeout: 15_000 })
|
||||
.toMatchObject({ src: expect.stringContaining('video-intro.mp4') })
|
||||
const ready = await page.evaluate(() =>
|
||||
(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm?.readyState ?? 0)
|
||||
expect(ready).toBeGreaterThanOrEqual(1) // metadata in = download underway
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 754 KiB |
|
After Width: | Height: | Size: 648 KiB |
|
After Width: | Height: | Size: 680 KiB |
|
After Width: | Height: | Size: 785 KiB |
|
After Width: | Height: | Size: 709 KiB |
|
After Width: | Height: | Size: 559 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 637 KiB |
|
After Width: | Height: | Size: 571 KiB |
|
After Width: | Height: | Size: 610 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { test, type Page } from '@playwright/test'
|
||||
|
||||
const SCREENSHOT_DIR = './e2e/screenshots'
|
||||
const PASSWORD = 'password123'
|
||||
|
||||
/** Set localStorage values to skip splash screen and onboarding */
|
||||
async function skipSplashAndOnboarding(page: Page) {
|
||||
await page.goto('/login')
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
})
|
||||
}
|
||||
|
||||
async function login(page: Page) {
|
||||
await skipSplashAndOnboarding(page)
|
||||
await page.goto('/login')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Wait for the password input to appear (server health check may delay it)
|
||||
const passwordInput = page.locator('input[type="password"]').first()
|
||||
await passwordInput.waitFor({ timeout: 15_000 })
|
||||
await passwordInput.fill(PASSWORD)
|
||||
|
||||
// Click the login/submit button
|
||||
const submitBtn = page
|
||||
.locator(
|
||||
'button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]',
|
||||
)
|
||||
.first()
|
||||
await submitBtn.click()
|
||||
|
||||
// Wait for navigation to dashboard
|
||||
await page.waitForURL('**/dashboard**', { timeout: 15_000 })
|
||||
await page.waitForLoadState('networkidle')
|
||||
// Wait for home page content to confirm dashboard is loaded
|
||||
await page.locator('text=Welcome Noderunner').waitFor({ timeout: 10_000 })
|
||||
await page.waitForTimeout(1500)
|
||||
}
|
||||
|
||||
/** Navigate to a dashboard child route via sidebar link click */
|
||||
async function navigateTo(page: Page, path: string, waitForText: string) {
|
||||
// Use in-page navigation to avoid full SPA reload
|
||||
await page.evaluate((p) => {
|
||||
window.history.pushState({}, '', p)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}, path)
|
||||
// Wait for the page-specific content to appear
|
||||
await page.locator(`text=${waitForText}`).first().waitFor({ timeout: 10_000 })
|
||||
// Let content settle after route change
|
||||
await page.waitForTimeout(800)
|
||||
}
|
||||
|
||||
async function screenshot(page: Page, name: string) {
|
||||
// Wait for any animations to settle
|
||||
await page.waitForTimeout(1000)
|
||||
await page.screenshot({
|
||||
path: `${SCREENSHOT_DIR}/${name}.png`,
|
||||
fullPage: true,
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Visual Regression — Public Pages', () => {
|
||||
test('login page', async ({ page }) => {
|
||||
await skipSplashAndOnboarding(page)
|
||||
await page.goto('/login')
|
||||
await page.waitForLoadState('networkidle')
|
||||
// Wait for server health check and form to become active
|
||||
await page.locator('input[type="password"]').first().waitFor({ timeout: 15_000 })
|
||||
await page.waitForTimeout(1000)
|
||||
await screenshot(page, '01-login')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Visual Regression — Dashboard Pages', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page)
|
||||
})
|
||||
|
||||
test('home / dashboard', async ({ page }) => {
|
||||
// Already on home after login
|
||||
await screenshot(page, '02-dashboard-home')
|
||||
})
|
||||
|
||||
test('apps list', async ({ page }) => {
|
||||
await navigateTo(page, '/dashboard/apps', 'My Apps')
|
||||
await screenshot(page, '03-apps-list')
|
||||
})
|
||||
|
||||
test('marketplace', async ({ page }) => {
|
||||
await navigateTo(page, '/dashboard/marketplace', 'App Store')
|
||||
await screenshot(page, '04-marketplace')
|
||||
})
|
||||
|
||||
test('cloud storage', async ({ page }) => {
|
||||
await navigateTo(page, '/dashboard/cloud', 'Cloud')
|
||||
await screenshot(page, '05-cloud')
|
||||
})
|
||||
|
||||
test('server', async ({ page }) => {
|
||||
await navigateTo(page, '/dashboard/server', 'Network')
|
||||
await screenshot(page, '06-server')
|
||||
})
|
||||
|
||||
test('web5', async ({ page }) => {
|
||||
await navigateTo(page, '/dashboard/web5', 'Web5')
|
||||
await screenshot(page, '07-web5')
|
||||
})
|
||||
|
||||
test('settings', async ({ page }) => {
|
||||
await navigateTo(page, '/dashboard/settings', 'Settings')
|
||||
await screenshot(page, '08-settings')
|
||||
})
|
||||
|
||||
test('chat', async ({ page }) => {
|
||||
await navigateTo(page, '/dashboard/chat', 'AI Assistant')
|
||||
await screenshot(page, '09-chat')
|
||||
})
|
||||
|
||||
test('federation', async ({ page }) => {
|
||||
await navigateTo(page, '/dashboard/server/federation', 'Federation')
|
||||
await screenshot(page, '10-federation')
|
||||
})
|
||||
|
||||
test('credentials', async ({ page }) => {
|
||||
await navigateTo(page, '/dashboard/web5/credentials', 'Credentials')
|
||||
await screenshot(page, '11-credentials')
|
||||
})
|
||||
|
||||
test('system update', async ({ page }) => {
|
||||
await navigateTo(page, '/dashboard/settings/update', 'System Update')
|
||||
await screenshot(page, '12-system-update')
|
||||
})
|
||||
})
|
||||