mirror of
https://github.com/solidtime-io/solidtime-desktop.git
synced 2026-05-07 20:32:27 +00:00
add basic e2e tests with api mocking
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: 'Checkout code'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 'Use Node.js'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: 'Install npm dependencies'
|
||||
run: npm ci
|
||||
|
||||
- name: 'Install Playwright Electron dependencies'
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: 'Build Electron app'
|
||||
run: npx electron-vite build
|
||||
|
||||
- name: 'Run E2E tests'
|
||||
run: xvfb-run --auto-servernum npx playwright test --config=e2e/playwright.config.ts
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Custom Playwright test fixture for Electron E2E testing.
|
||||
*
|
||||
* Provides:
|
||||
* - `electronApp`: The launched Electron application instance
|
||||
* - `page`: The main window's Page object (with API mocks and auth pre-seeded)
|
||||
* - `mockState`: Mutable mock state object for controlling API responses per-test
|
||||
*/
|
||||
|
||||
import { test as base, type ElectronApplication, type Page } from '@playwright/test'
|
||||
import { _electron as electron } from 'playwright'
|
||||
import { setupApiMocks, type MockState } from '../mocks/api-handler'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const appPath = path.resolve(__dirname, '../..')
|
||||
const isCI = !!process.env.CI
|
||||
|
||||
function createTempUserDataDir(): string {
|
||||
return fs.mkdtempSync(path.join(appPath, '.e2e-userdata-'))
|
||||
}
|
||||
|
||||
function cleanupUserDataDir(dir: string): void {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the main window (index.html, not index-mini.html).
|
||||
* Waits until a window with the main index.html URL is available.
|
||||
*/
|
||||
async function getMainWindow(electronApp: ElectronApplication): Promise<Page> {
|
||||
const maxAttempts = 20
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
for (const win of electronApp.windows()) {
|
||||
const url = win.url()
|
||||
if (url.includes('index.html') && !url.includes('index-mini.html')) {
|
||||
return win
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
|
||||
// Fallback: wait for a new window event
|
||||
return new Promise<Page>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Main window not found')), 10000)
|
||||
electronApp.on('window', (page) => {
|
||||
if (page.url().includes('index.html') && !page.url().includes('index-mini.html')) {
|
||||
clearTimeout(timeout)
|
||||
resolve(page)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type ElectronTestFixtures = {
|
||||
electronApp: ElectronApplication
|
||||
page: Page
|
||||
mockState: MockState
|
||||
}
|
||||
|
||||
/**
|
||||
* Base fixture for authenticated Electron tests.
|
||||
*/
|
||||
export const test = base.extend<ElectronTestFixtures>({
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
electronApp: async ({}, use) => {
|
||||
const userDataDir = createTempUserDataDir()
|
||||
const app = await electron.launch({
|
||||
args: [...(isCI ? ['--no-sandbox'] : []), '--user-data-dir=' + userDataDir, appPath],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
E2E_TESTING: 'true',
|
||||
},
|
||||
})
|
||||
await use(app)
|
||||
await app.close()
|
||||
cleanupUserDataDir(userDataDir)
|
||||
},
|
||||
|
||||
mockState: [
|
||||
async ({ electronApp }, use) => {
|
||||
const mainPage = await getMainWindow(electronApp)
|
||||
await mainPage.waitForLoadState('domcontentloaded')
|
||||
|
||||
// Wait for mini window to be ready too
|
||||
await mainPage.waitForTimeout(500)
|
||||
|
||||
const allWindows = electronApp.windows()
|
||||
|
||||
// 1. Set up API route mocks on ALL windows.
|
||||
// Both main and mini windows make API calls, so both need interception.
|
||||
const state = await setupApiMocks(mainPage)
|
||||
for (const win of allWindows) {
|
||||
if (win !== mainPage) {
|
||||
await setupApiMocks(win)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Seed localStorage with auth tokens (shared across same-origin windows)
|
||||
await mainPage.evaluate(
|
||||
(data) => {
|
||||
localStorage.setItem('access_token', 'mock-access-token')
|
||||
localStorage.setItem('refresh_token', 'mock-refresh-token')
|
||||
localStorage.setItem('instance_endpoint', 'https://mock.solidtime.io')
|
||||
localStorage.setItem('currentMembershipId', JSON.stringify(data.membershipId))
|
||||
},
|
||||
{ membershipId: state.membership.id }
|
||||
)
|
||||
|
||||
// 3. Add init scripts ONLY on non-main windows (mini windows).
|
||||
// IMPORTANT: addInitScript on the main page breaks page.route() interception,
|
||||
// causing API mocks to stop working. Only mini windows need it to prevent
|
||||
// their useStorage initialization from clearing the auth tokens.
|
||||
for (const win of allWindows) {
|
||||
if (win !== mainPage) {
|
||||
await win.addInitScript(
|
||||
(data) => {
|
||||
localStorage.setItem('access_token', 'mock-access-token')
|
||||
localStorage.setItem('refresh_token', 'mock-refresh-token')
|
||||
localStorage.setItem('instance_endpoint', 'https://mock.solidtime.io')
|
||||
localStorage.setItem(
|
||||
'currentMembershipId',
|
||||
JSON.stringify(data.membershipId)
|
||||
)
|
||||
},
|
||||
{ membershipId: state.membership.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Reload windows sequentially: mini windows first, then main.
|
||||
// Mini windows must reload first so their addInitScript seeds localStorage
|
||||
// before the main window reads it during bootstrap.
|
||||
for (const win of allWindows) {
|
||||
if (win !== mainPage) {
|
||||
await win.reload()
|
||||
await win.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
}
|
||||
await mainPage.reload()
|
||||
await mainPage.waitForLoadState('domcontentloaded')
|
||||
|
||||
// 5. Wait for the main window app to fully render
|
||||
await mainPage.waitForURL(/.*#\/.*/, { timeout: 10000 })
|
||||
|
||||
await use(state)
|
||||
},
|
||||
{ auto: false },
|
||||
],
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
page: async ({ electronApp, mockState }, use) => {
|
||||
// Depend on mockState so mocks are set up before page is used
|
||||
const page = await getMainWindow(electronApp)
|
||||
await use(page)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Fixture for unauthenticated tests (login page, instance settings).
|
||||
*/
|
||||
export const unauthenticatedTest = base.extend<{ electronApp: ElectronApplication; page: Page }>({
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
electronApp: async ({}, use) => {
|
||||
const userDataDir = createTempUserDataDir()
|
||||
const app = await electron.launch({
|
||||
args: [...(isCI ? ['--no-sandbox'] : []), '--user-data-dir=' + userDataDir, appPath],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
E2E_TESTING: 'true',
|
||||
},
|
||||
})
|
||||
await use(app)
|
||||
await app.close()
|
||||
cleanupUserDataDir(userDataDir)
|
||||
},
|
||||
|
||||
page: async ({ electronApp }, use) => {
|
||||
const page = await getMainWindow(electronApp)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
|
||||
// Ensure no auth tokens are present
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('currentMembershipId')
|
||||
})
|
||||
|
||||
await page.reload()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
await use(page)
|
||||
},
|
||||
})
|
||||
|
||||
export { expect } from '@playwright/test'
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Centralized API route handler for E2E tests.
|
||||
* Intercepts all API requests from the Electron renderer and returns mock data.
|
||||
*
|
||||
* Uses a single catch-all route to avoid glob pattern issues with query parameters.
|
||||
*/
|
||||
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import { createDefaultMockData, createTimeEntry } from './data'
|
||||
|
||||
export interface MockState {
|
||||
user: ReturnType<typeof createDefaultMockData>['user']
|
||||
organization: ReturnType<typeof createDefaultMockData>['organization']
|
||||
membership: ReturnType<typeof createDefaultMockData>['membership']
|
||||
projects: ReturnType<typeof createDefaultMockData>['projects']
|
||||
tags: ReturnType<typeof createDefaultMockData>['tags']
|
||||
tasks: ReturnType<typeof createDefaultMockData>['tasks']
|
||||
clients: ReturnType<typeof createDefaultMockData>['clients']
|
||||
timeEntries: ReturnType<typeof createDefaultMockData>['timeEntries']
|
||||
activeTimeEntry: ReturnType<typeof createTimeEntry> | null
|
||||
}
|
||||
|
||||
function jsonResponse(route: Route, data: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the pathname from a URL (without query params).
|
||||
*/
|
||||
function getPathname(url: string): string {
|
||||
try {
|
||||
return new URL(url).pathname
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register API route handlers on a Playwright page.
|
||||
* Returns a mutable state object that tests can modify to change mock responses.
|
||||
*/
|
||||
export async function setupApiMocks(page: Page): Promise<MockState> {
|
||||
const defaultData = createDefaultMockData()
|
||||
|
||||
const state: MockState = {
|
||||
...defaultData,
|
||||
activeTimeEntry: null,
|
||||
}
|
||||
|
||||
// Single catch-all handler for all API and OAuth requests.
|
||||
// This avoids glob pattern issues with query parameters.
|
||||
await page.route('**/*', (route) => {
|
||||
const url = route.request().url()
|
||||
const method = route.request().method()
|
||||
const pathname = getPathname(url)
|
||||
|
||||
// Only intercept API and OAuth requests
|
||||
if (!pathname.includes('/api/v1/') && !pathname.includes('/oauth/')) {
|
||||
return route.fallback()
|
||||
}
|
||||
|
||||
// POST /oauth/token (token refresh)
|
||||
if (pathname.endsWith('/oauth/token') && method === 'POST') {
|
||||
return jsonResponse(route, {
|
||||
access_token: 'mock-refreshed-access-token',
|
||||
refresh_token: 'mock-refreshed-refresh-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/users/me/time-entries/active
|
||||
if (pathname.endsWith('/users/me/time-entries/active') && method === 'GET') {
|
||||
if (state.activeTimeEntry) {
|
||||
return jsonResponse(route, { data: state.activeTimeEntry })
|
||||
}
|
||||
return jsonResponse(route, {
|
||||
data: {
|
||||
id: '',
|
||||
description: null,
|
||||
user_id: '',
|
||||
start: '',
|
||||
end: null,
|
||||
duration: null,
|
||||
task_id: null,
|
||||
project_id: null,
|
||||
tags: [],
|
||||
billable: false,
|
||||
organization_id: '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/users/me/memberships
|
||||
if (pathname.endsWith('/users/me/memberships') && method === 'GET') {
|
||||
return jsonResponse(route, { data: [state.membership] })
|
||||
}
|
||||
|
||||
// GET /api/v1/users/me
|
||||
if (pathname.endsWith('/users/me') && method === 'GET') {
|
||||
return jsonResponse(route, { data: state.user })
|
||||
}
|
||||
|
||||
// /api/v1/organizations/:org/time-entries/:id (specific entry)
|
||||
const timeEntryMatch = pathname.match(/\/organizations\/[^/]+\/time-entries\/([^/]+)$/)
|
||||
if (timeEntryMatch && timeEntryMatch[1] !== 'active') {
|
||||
if (method === 'PUT') {
|
||||
const body = route.request().postDataJSON()
|
||||
const updatedEntry = { ...state.activeTimeEntry, ...body }
|
||||
state.activeTimeEntry = null
|
||||
return jsonResponse(route, { data: updatedEntry })
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fallback()
|
||||
}
|
||||
|
||||
// /api/v1/organizations/:org/time-entries (collection)
|
||||
if (pathname.match(/\/organizations\/[^/]+\/time-entries$/)) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse(route, { data: state.timeEntries })
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const body = route.request().postDataJSON()
|
||||
const newEntry = createTimeEntry(state.organization.id, state.user.id, {
|
||||
...body,
|
||||
end: null,
|
||||
duration: null,
|
||||
})
|
||||
state.activeTimeEntry = newEntry
|
||||
return jsonResponse(route, { data: newEntry }, 201)
|
||||
}
|
||||
if (method === 'PATCH') {
|
||||
return jsonResponse(route, { data: state.timeEntries })
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fallback()
|
||||
}
|
||||
|
||||
// GET /api/v1/organizations/:org/projects
|
||||
if (pathname.match(/\/organizations\/[^/]+\/projects$/) && method === 'GET') {
|
||||
return jsonResponse(route, { data: state.projects })
|
||||
}
|
||||
|
||||
// GET /api/v1/organizations/:org/tags
|
||||
if (pathname.match(/\/organizations\/[^/]+\/tags$/) && method === 'GET') {
|
||||
return jsonResponse(route, { data: state.tags })
|
||||
}
|
||||
|
||||
// GET /api/v1/organizations/:org/tasks
|
||||
if (pathname.match(/\/organizations\/[^/]+\/tasks$/) && method === 'GET') {
|
||||
return jsonResponse(route, { data: state.tasks })
|
||||
}
|
||||
|
||||
// GET /api/v1/organizations/:org/clients
|
||||
if (pathname.match(/\/organizations\/[^/]+\/clients$/) && method === 'GET') {
|
||||
return jsonResponse(route, { data: state.clients })
|
||||
}
|
||||
|
||||
// Unhandled API request — let it through (will likely fail with net error)
|
||||
console.warn(`Unhandled API request: ${method} ${url}`)
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Mock data factories for E2E tests.
|
||||
* Produces realistic data matching @solidtime/api response shapes.
|
||||
*/
|
||||
|
||||
let counter = 0
|
||||
function nextId(): string {
|
||||
counter++
|
||||
return `00000000-0000-0000-0000-${String(counter).padStart(12, '0')}`
|
||||
}
|
||||
|
||||
export function resetIds(): void {
|
||||
counter = 0
|
||||
}
|
||||
|
||||
export function createUser(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: nextId(),
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
profile_photo_url: null,
|
||||
timezone: 'Europe/Vienna',
|
||||
week_start: 'monday',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createOrganization(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: nextId(),
|
||||
name: 'Test Organization',
|
||||
is_personal: false,
|
||||
billable_rate: null,
|
||||
employees_can_see_billable_rates: false,
|
||||
employees_can_manage_tasks: true,
|
||||
prevent_overlapping_time_entries: false,
|
||||
currency: 'EUR',
|
||||
currency_symbol: '\u20ac',
|
||||
number_format: 'point-comma' as const,
|
||||
currency_format: 'symbol-after-with-space' as const,
|
||||
date_format: 'point-separated-d-m-yyyy' as const,
|
||||
interval_format: 'hours-minutes-colon-separated' as const,
|
||||
time_format: '24-hours' as const,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createMembership(
|
||||
user: ReturnType<typeof createUser>,
|
||||
organization: ReturnType<typeof createOrganization>,
|
||||
overrides: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
id: nextId(),
|
||||
user_id: user.id,
|
||||
organization_id: organization.id,
|
||||
role: 'owner',
|
||||
is_placeholder: false,
|
||||
billable_rate: null,
|
||||
organization: {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
is_personal: organization.is_personal,
|
||||
billable_rate: organization.billable_rate,
|
||||
employees_can_see_billable_rates: organization.employees_can_see_billable_rates,
|
||||
employees_can_manage_tasks: organization.employees_can_manage_tasks,
|
||||
prevent_overlapping_time_entries: organization.prevent_overlapping_time_entries,
|
||||
currency: organization.currency,
|
||||
currency_symbol: organization.currency_symbol,
|
||||
number_format: organization.number_format,
|
||||
currency_format: organization.currency_format,
|
||||
date_format: organization.date_format,
|
||||
interval_format: organization.interval_format,
|
||||
time_format: organization.time_format,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createProject(organizationId: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: nextId(),
|
||||
name: 'Test Project',
|
||||
color: '#3b82f6',
|
||||
client_id: null,
|
||||
is_archived: false,
|
||||
billable_rate: null,
|
||||
is_billable: false,
|
||||
estimated_time: null,
|
||||
spent_time: 3600,
|
||||
is_public: true,
|
||||
organization_id: organizationId,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createTag(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: nextId(),
|
||||
name: 'Test Tag',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createTask(projectId: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: nextId(),
|
||||
name: 'Test Task',
|
||||
is_done: false,
|
||||
project_id: projectId,
|
||||
estimated_time: null,
|
||||
spent_time: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createClient(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: nextId(),
|
||||
name: 'Test Client',
|
||||
is_archived: false,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createTimeEntry(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
overrides: Record<string, unknown> = {}
|
||||
) {
|
||||
const id = nextId()
|
||||
return {
|
||||
id,
|
||||
description: 'Working on feature',
|
||||
user_id: userId,
|
||||
start: '2025-01-20T09:00:00Z',
|
||||
end: '2025-01-20T10:00:00Z',
|
||||
duration: 3600,
|
||||
task_id: null,
|
||||
project_id: null,
|
||||
tags: [] as string[],
|
||||
billable: false,
|
||||
organization_id: organizationId,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a complete set of mock data for a typical authenticated session.
|
||||
*/
|
||||
export function createDefaultMockData() {
|
||||
resetIds()
|
||||
|
||||
const user = createUser()
|
||||
const organization = createOrganization()
|
||||
const membership = createMembership(user, organization)
|
||||
const project = createProject(organization.id, { name: 'Website Redesign', color: '#3b82f6' })
|
||||
const project2 = createProject(organization.id, { name: 'API Development', color: '#ef4444' })
|
||||
const tag = createTag({ name: 'frontend' })
|
||||
const tag2 = createTag({ name: 'backend' })
|
||||
const task = createTask(project.id, { name: 'Implement landing page' })
|
||||
const client = createClient({ name: 'Acme Corp' })
|
||||
|
||||
const timeEntries = [
|
||||
createTimeEntry(organization.id, user.id, {
|
||||
description: 'Implement navigation component',
|
||||
start: '2025-01-20T09:00:00Z',
|
||||
end: '2025-01-20T11:30:00Z',
|
||||
duration: 9000,
|
||||
project_id: project.id,
|
||||
tags: [tag.id],
|
||||
}),
|
||||
createTimeEntry(organization.id, user.id, {
|
||||
description: 'Code review',
|
||||
start: '2025-01-20T13:00:00Z',
|
||||
end: '2025-01-20T14:00:00Z',
|
||||
duration: 3600,
|
||||
project_id: project2.id,
|
||||
tags: [tag2.id],
|
||||
}),
|
||||
createTimeEntry(organization.id, user.id, {
|
||||
description: 'API endpoint development',
|
||||
start: '2025-01-19T10:00:00Z',
|
||||
end: '2025-01-19T12:00:00Z',
|
||||
duration: 7200,
|
||||
project_id: project2.id,
|
||||
}),
|
||||
]
|
||||
|
||||
return {
|
||||
user,
|
||||
organization,
|
||||
membership,
|
||||
projects: [project, project2],
|
||||
tags: [tag, tag2],
|
||||
tasks: [task],
|
||||
clients: [client],
|
||||
timeEntries,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 30000,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1, // Electron tests must run serially
|
||||
reporter: process.env.CI ? 'github' : 'list',
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { unauthenticatedTest, test, expect } from '../fixtures/electron-test'
|
||||
|
||||
unauthenticatedTest.describe('Login page (unauthenticated)', () => {
|
||||
unauthenticatedTest('shows the login button when not authenticated', async ({ page }) => {
|
||||
const loginButton = page.getByText(/log in with solidtime/i)
|
||||
await expect(loginButton).toBeVisible()
|
||||
})
|
||||
|
||||
unauthenticatedTest('shows the welcome text on the login page', async ({ page }) => {
|
||||
const welcomeText = page.getByText(/welcome to the solidtime desktop client/i)
|
||||
await expect(welcomeText).toBeVisible()
|
||||
})
|
||||
|
||||
unauthenticatedTest('can open instance settings modal', async ({ page }) => {
|
||||
const settingsButton = page.getByText(/instance settings/i)
|
||||
await expect(settingsButton).toBeVisible()
|
||||
await settingsButton.click()
|
||||
|
||||
const modal = page.getByRole('dialog')
|
||||
await expect(modal).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Authenticated state', () => {
|
||||
test('shows the main app when authenticated', async ({ page }) => {
|
||||
// The footer shows "No timer running" when authenticated with no active timer
|
||||
const footer = page.getByText(/no timer running/i)
|
||||
await expect(footer).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('shows the time page by default', async ({ page }) => {
|
||||
await page.waitForURL(/#\/time/, { timeout: 5000 })
|
||||
expect(page.url()).toContain('#/time')
|
||||
})
|
||||
|
||||
test('shows the sidebar navigation', async ({ page }) => {
|
||||
// The sidebar contains navigation buttons (4 icon buttons)
|
||||
const sidebarButtons = page.locator('.w-14.border-r button, .w-14.border-r [role="button"]')
|
||||
await expect(sidebarButtons.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from '../fixtures/electron-test'
|
||||
|
||||
test.describe('Navigation', () => {
|
||||
test('starts on the time page by default', async ({ page }) => {
|
||||
expect(page.url()).toContain('#/time')
|
||||
})
|
||||
|
||||
test('can navigate to the calendar page via sidebar', async ({ page }) => {
|
||||
// Sidebar buttons are icon-only in order: Time, Calendar, Statistics, Settings
|
||||
// Click the 2nd button (Calendar)
|
||||
const sidebarButtons = page.locator('.w-14.border-r button, .w-14.border-r [role="button"]')
|
||||
await sidebarButtons.nth(1).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
expect(page.url()).toContain('#/calendar')
|
||||
})
|
||||
|
||||
test('can navigate to the statistics page via sidebar', async ({ page }) => {
|
||||
// Click the 3rd button (Statistics)
|
||||
const sidebarButtons = page.locator('.w-14.border-r button, .w-14.border-r [role="button"]')
|
||||
await sidebarButtons.nth(2).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
expect(page.url()).toContain('#/statistics')
|
||||
})
|
||||
|
||||
test('can navigate to the settings page via sidebar', async ({ page }) => {
|
||||
// Click the 4th button (Settings)
|
||||
const sidebarButtons = page.locator('.w-14.border-r button, .w-14.border-r [role="button"]')
|
||||
await sidebarButtons.nth(3).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
expect(page.url()).toContain('#/settings')
|
||||
})
|
||||
|
||||
test('can navigate back to the time page via sidebar', async ({ page }) => {
|
||||
// Navigate away first
|
||||
await page.evaluate(() => (window.location.hash = '#/settings'))
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Click the 1st button (Time)
|
||||
const sidebarButtons = page.locator('.w-14.border-r button, .w-14.border-r [role="button"]')
|
||||
await sidebarButtons.nth(0).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
expect(page.url()).toContain('#/time')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { test, expect } from '../fixtures/electron-test'
|
||||
|
||||
test.describe('Organization switcher', () => {
|
||||
test('displays the current organization name', async ({ page, mockState }) => {
|
||||
const orgName = page.getByText(mockState.organization.name)
|
||||
await expect(orgName).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test, expect } from '../fixtures/electron-test'
|
||||
|
||||
test.describe('Settings page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to settings via URL hash
|
||||
await page.evaluate(() => (window.location.hash = '#/settings'))
|
||||
await page.waitForTimeout(1000)
|
||||
})
|
||||
|
||||
test('displays user information', async ({ page }) => {
|
||||
const userName = page.getByText('Test User')
|
||||
await expect(userName).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const userEmail = page.getByText('test@example.com')
|
||||
await expect(userEmail).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows the settings heading', async ({ page }) => {
|
||||
const heading = page.getByText('Settings', { exact: true }).first()
|
||||
await expect(heading).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('shows the logout button', async ({ page }) => {
|
||||
const logoutButton = page.getByText(/logout/i)
|
||||
await expect(logoutButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('logout returns to login page', async ({ page }) => {
|
||||
const logoutButton = page.getByText(/logout/i)
|
||||
await logoutButton.click()
|
||||
|
||||
// Should show the login page
|
||||
const loginButton = page.getByText(/log in with solidtime/i)
|
||||
await expect(loginButton).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('displays preferences section', async ({ page }) => {
|
||||
const preferencesHeading = page.getByText('Preferences')
|
||||
await expect(preferencesHeading).toBeVisible()
|
||||
})
|
||||
|
||||
test('displays widget toggle', async ({ page }) => {
|
||||
const widgetLabel = page.getByText(/show timetracker widget/i)
|
||||
await expect(widgetLabel).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from '../fixtures/electron-test'
|
||||
|
||||
test.describe('Statistics page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to statistics via URL hash
|
||||
await page.evaluate(() => (window.location.hash = '#/statistics'))
|
||||
await page.waitForTimeout(1000)
|
||||
})
|
||||
|
||||
test('statistics page loads', async ({ page }) => {
|
||||
expect(page.url()).toContain('#/statistics')
|
||||
})
|
||||
|
||||
test('shows window activity statistics heading', async ({ page }) => {
|
||||
// The statistics page shows either the stats content or a message about
|
||||
// activity tracking being disabled
|
||||
const heading = page.getByText(/window activity statistics/i)
|
||||
const disabledMsg = page.getByText(/activity tracking is disabled/i)
|
||||
|
||||
// One of these should be visible
|
||||
const headingVisible = await heading.isVisible().catch(() => false)
|
||||
const disabledVisible = await disabledMsg.isVisible().catch(() => false)
|
||||
|
||||
expect(headingVisible || disabledVisible).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { test, expect } from '../fixtures/electron-test'
|
||||
|
||||
test.describe('Time tracking', () => {
|
||||
test('displays time entries', async ({ page }) => {
|
||||
// Verify time entry descriptions are visible
|
||||
const entry = page.getByText('Implement navigation component')
|
||||
await expect(entry).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('shows "No timer running" in footer when no active timer', async ({ page }) => {
|
||||
const footer = page.getByText(/no timer running/i)
|
||||
await expect(footer).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('displays project names in time entries', async ({ page }) => {
|
||||
// The mock data includes projects "Website Redesign" and "API Development"
|
||||
const project = page.getByText('Website Redesign')
|
||||
await expect(project).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
Generated
+64
@@ -27,6 +27,7 @@
|
||||
"@electron-toolkit/eslint-config-ts": "^2.0.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@rushstack/eslint-patch": "^1.10.3",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
@@ -3369,6 +3370,22 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
|
||||
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/instrumentation": {
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-5.22.0.tgz",
|
||||
@@ -10430,6 +10447,53 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
|
||||
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
|
||||
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/plist": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
||||
|
||||
+3
-1
@@ -20,7 +20,8 @@
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:mac": "npm run build && electron-builder --mac",
|
||||
"build:linux": "npm run build && electron-builder --linux"
|
||||
"build:linux": "npm run build && electron-builder --linux",
|
||||
"test:e2e": "npx playwright test --config=e2e/playwright.config.ts"
|
||||
},
|
||||
"build:": {
|
||||
"generateUpdatesFilesForAllChannels": true,
|
||||
@@ -48,6 +49,7 @@
|
||||
"@electron-toolkit/eslint-config-ts": "^2.0.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@rushstack/eslint-patch": "^1.10.3",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
|
||||
@@ -2,10 +2,11 @@ import { db } from './db/client'
|
||||
import { windowActivities, validateNewWindowActivity } from './db/schema'
|
||||
import { getAppSettings } from './settings'
|
||||
import { hasScreenRecordingPermission } from './permissions'
|
||||
import { ipcMain } from 'electron'
|
||||
import { ipcMain, app } from 'electron'
|
||||
import { logger } from './logger'
|
||||
|
||||
// Lazy-load x-win module with detailed error reporting
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let xWinModule: any = null
|
||||
let xWinLoadError: Error | null = null
|
||||
|
||||
@@ -20,7 +21,7 @@ async function loadXWinModule() {
|
||||
console.log('Process versions:', JSON.stringify(process.versions, null, 2))
|
||||
console.log('__dirname:', __dirname)
|
||||
console.log('process.cwd():', process.cwd())
|
||||
console.log('app.isPackaged:', require('electron').app.isPackaged)
|
||||
console.log('app.isPackaged:', app.isPackaged)
|
||||
|
||||
xWinModule = await import('@miniben90/x-win')
|
||||
console.log('=== @miniben90/x-win LOADED SUCCESSFULLY ===')
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
|
||||
// Lazy-load x-win module with detailed error reporting
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let xWinModule: any = null
|
||||
let xWinLoadError: Error | null = null
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isE2ETesting(): boolean {
|
||||
return process.env.E2E_TESTING === 'true'
|
||||
}
|
||||
+19
-8
@@ -19,6 +19,8 @@ import * as Sentry from '@sentry/electron/main'
|
||||
import path from 'node:path'
|
||||
import { stopIdleMonitoring } from './idleMonitor'
|
||||
|
||||
import { isE2ETesting } from './env'
|
||||
|
||||
// Global error handlers to capture full error details
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('=== UNCAUGHT EXCEPTION ===')
|
||||
@@ -27,11 +29,13 @@ process.on('uncaughtException', (error) => {
|
||||
console.error('Error stack:', error.stack)
|
||||
console.error('Full error object:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2))
|
||||
|
||||
// Show error dialog
|
||||
dialog.showErrorBox(
|
||||
'A JavaScript error occurred in the main process',
|
||||
`${error.name}: ${error.message}\n\nStack:\n${error.stack}`
|
||||
)
|
||||
// Show error dialog (skip in E2E testing to avoid blocking)
|
||||
if (!isE2ETesting()) {
|
||||
dialog.showErrorBox(
|
||||
'A JavaScript error occurred in the main process',
|
||||
`${error.name}: ${error.message}\n\nStack:\n${error.stack}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
@@ -43,9 +47,11 @@ process.on('unhandledRejection', (reason, promise) => {
|
||||
}
|
||||
})
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
if (!isE2ETesting()) {
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
|
||||
initializeAutoUpdater()
|
||||
@@ -191,6 +197,11 @@ app.on('window-all-closed', () => {
|
||||
|
||||
// Save active periods before the app quits
|
||||
app.on('before-quit', async (event) => {
|
||||
// Skip cleanup during E2E testing to avoid slow shutdown
|
||||
if (isE2ETesting()) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { join } from 'path'
|
||||
import { app, BrowserWindow, ipcMain, shell } from 'electron'
|
||||
import { isE2ETesting } from './env'
|
||||
|
||||
let mainWindowInstance: BrowserWindow | null = null
|
||||
|
||||
@@ -28,8 +29,10 @@ export function initializeMainWindow(icon: string) {
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
if (!isE2ETesting()) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
let forcequit = false
|
||||
@@ -44,7 +47,9 @@ export function initializeMainWindow(icon: string) {
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
if (!isE2ETesting()) {
|
||||
mainWindow.show()
|
||||
}
|
||||
})
|
||||
|
||||
mainWindowInstance = mainWindow
|
||||
@@ -59,7 +64,7 @@ export function registerMainWindowListeners(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.send('stopTimer')
|
||||
})
|
||||
ipcMain.on('showMainWindow', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow && !isE2ETesting()) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { join } from 'path'
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { isE2ETesting } from './env'
|
||||
|
||||
export function initializeMiniWindow(icon: string) {
|
||||
const miniWindow = new BrowserWindow({
|
||||
@@ -27,8 +28,10 @@ export function initializeMiniWindow(icon: string) {
|
||||
|
||||
export function registerMiniWindowListeners(miniWindow: BrowserWindow) {
|
||||
ipcMain.on('showMiniWindow', () => {
|
||||
miniWindow.show()
|
||||
miniWindow.focus()
|
||||
if (!isE2ETesting()) {
|
||||
miniWindow.show()
|
||||
miniWindow.focus()
|
||||
}
|
||||
})
|
||||
ipcMain.on('hideMiniWindow', () => {
|
||||
miniWindow.hide()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { apiClient } from './api.ts'
|
||||
|
||||
export function getAllTasks(currentOrganizationId: string | null) {
|
||||
if (currentOrganizationId === null) {
|
||||
if (!currentOrganizationId) {
|
||||
throw new Error('No current organization id - all tasks')
|
||||
}
|
||||
return apiClient.value.getTasks({
|
||||
|
||||
Reference in New Issue
Block a user