Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5ac9d74
add basic e2e tests with api mocking
Onatcer Jan 27, 2026
970a4dc
split startTimer into startTimer (UI values) and continueLastTimer (l…
Onatcer Jan 27, 2026
c302ab5
add 10 minute bucket grouping for activities
Onatcer Jan 27, 2026
697bcc6
fix electron focus behaviour on navigation items showing unnecessary …
Onatcer Jan 27, 2026
6422bd0
make sure that the current time entry does not reset when on an empty…
Onatcer Feb 16, 2026
6691b2f
improve activity detection with powermonitor events
Onatcer Mar 3, 2026
fc22d53
update styles to new ui package exports
Onatcer Mar 4, 2026
f524fff
add activity data delete options with time range
Onatcer Mar 4, 2026
ab5169c
use select dropdown from ui package for organization switcher
Onatcer Mar 5, 2026
4b7bd55
fix navigation item hover color consistency on active item
Onatcer Mar 5, 2026
095e3f3
load updates in the background
Onatcer Mar 10, 2026
8209ea6
fix stale active timer not resetting correctly
Onatcer Mar 12, 2026
5965a91
mount and unmout listeners on power monitor events to avoid race
Onatcer Mar 12, 2026
e138f0e
fix page and query invalidation
Onatcer Mar 26, 2026
043d971
update solidtime ui package
Onatcer Mar 26, 2026
19d3888
fix duplicate query invalidations
Onatcer Mar 26, 2026
3bd0989
add e2e mocks for post requests
Onatcer Mar 26, 2026
be4aa7a
fix e2e test selectors
Onatcer Mar 26, 2026
ceed2b1
Pass organization billable rate to components
Onatcer Mar 26, 2026
a0241b4
Bump x-win to 3.3.0 and add preview script
Onatcer Mar 26, 2026
7946acc
Add windowTitle and optimize activity bucketing
Onatcer Mar 26, 2026
4ca5f50
fix build electron-builder config
Onatcer Mar 26, 2026
2fc6306
add seperate arm and x64 builds for mac
Onatcer Mar 29, 2026
aa97ef5
Add support for fetching domain favicons
Onatcer Mar 29, 2026
69733ca
make activity bucket interval configurable and improve interface naming
Onatcer Mar 29, 2026
c69215e
fix calendar performance issues. only load current week activities.
Onatcer Mar 29, 2026
5173504
fix ipc type and format fixes
Onatcer Mar 29, 2026
e60b124
add window activities db indexes
Onatcer Mar 29, 2026
391add0
chore: update ui and x-win
Onatcer Mar 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 5 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ jobs:
include:
- os: macos-latest
platform: mac
arch: universal
arch: arm64
- os: macos-13
platform: mac
arch: x64
- os: ubuntu-latest
platform: linux
arch: x64
Expand Down Expand Up @@ -49,10 +52,6 @@ jobs:
- name: Install dependencies
run: npm install

- name: Install macOS x64 native module
if: matrix.platform == 'mac'
run: npm install --save-dev @miniben90/x-win-darwin-x64 --force

- name: Build
run: npm run build

Expand All @@ -73,7 +72,7 @@ jobs:
if: matrix.platform == 'mac'
run: |
echo "$API_KEY" > apple.p8
npx electron-builder --mac --universal --publish always
npx electron-builder --mac --${{ matrix.arch }} --publish always
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
Expand Down
4 changes: 4 additions & 0 deletions drizzle/0000_panoramic_catseye.sql
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ CREATE TABLE `window_activities` (
`process_id` integer,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `idx_activity_periods_start_end` ON `activity_periods` (`start`, `end`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `idx_window_activities_timestamp` ON `window_activities` (`timestamp`);
207 changes: 207 additions & 0 deletions e2e/fixtures/electron-test.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading