add basic e2e tests with api mocking

This commit is contained in:
Gregor Vostrak
2026-01-27 17:08:51 +01:00
parent 056fa31078
commit 05d26bef65
20 changed files with 923 additions and 18 deletions
+32
View File
@@ -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
+207
View File
@@ -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'
+173
View File
@@ -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
}
+202
View File
@@ -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,
}
}
+12
View File
@@ -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',
},
})
+41
View File
@@ -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 })
})
})
+48
View File
@@ -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')
})
})
+8
View File
@@ -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 })
})
})
+46
View File
@@ -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()
})
})
+26
View File
@@ -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)
})
})
+20
View File
@@ -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 })
})
})
+64
View File
@@ -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
View File
@@ -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",
+3 -2
View File
@@ -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 ===')
+1
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
export function isE2ETesting(): boolean {
return process.env.E2E_TESTING === 'true'
}
+19 -8
View File
@@ -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 {
+9 -4
View File
@@ -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()
}
+5 -2
View File
@@ -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 -1
View File
@@ -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({