From 432363aede27b95fe0bcf9676231cddb5492b9c0 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Fri, 11 Jul 2025 19:27:03 +0200 Subject: [PATCH 1/3] Make component testing possible Removes some dependence on reactive variables in favor of using svelte stores, meaning we should not use $effect in any .svelte.ts constructors. Without effects we no longer have lifecycle management, so we never unsubscribe from dependencies. This isn't ideal, but it's also realistically not an immediate issue. The benefit of this is we can easily create component tests using Cypress, and necessary to the `CommitMessageEditor` tests I have been working on. --- apps/desktop/src/lib/irc/ircService.svelte.ts | 37 ++++++++----------- .../selection/uncommittedService.svelte.ts | 6 +-- .../src/lib/state/clientState.svelte.ts | 29 ++++++--------- apps/desktop/src/lib/state/context.ts | 3 +- .../src/lib/state/customHooks.svelte.ts | 25 ++++++++----- apps/desktop/src/lib/state/uiState.svelte.ts | 7 ++-- .../src/lib/testing/mockGitHubApi.svelte.ts | 3 +- apps/desktop/src/routes/+layout.svelte | 7 +--- 8 files changed, 54 insertions(+), 63 deletions(-) diff --git a/apps/desktop/src/lib/irc/ircService.svelte.ts b/apps/desktop/src/lib/irc/ircService.svelte.ts index 3c01e6ebd4..9638596314 100644 --- a/apps/desktop/src/lib/irc/ircService.svelte.ts +++ b/apps/desktop/src/lib/irc/ircService.svelte.ts @@ -25,7 +25,7 @@ import persistReducer from 'redux-persist/es/persistReducer'; import storage from 'redux-persist/lib/storage'; import type { IrcClient } from '$lib/irc/ircClient.svelte'; import type { IrcEvent } from '$lib/irc/parser'; -import type { IrcChannel, IrcChat, WhoInfo } from '$lib/irc/types'; +import type { IrcChannel, IrcChat, IRCState, WhoInfo } from '$lib/irc/types'; import type { ClientState } from '$lib/state/clientState.svelte'; import type { Reactive } from '@gitbutler/shared/storeUtils'; import type { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit'; @@ -49,32 +49,25 @@ export class IrcService { }; clientState.inject(ircSlice.reducerPath, persistReducer(persistConfig, ircSlice.reducer)); + const store = clientState.rootState; - $effect(() => { - if (clientState.reactiveState) { - if (ircSlice.reducerPath in clientState.reactiveState) { - // @ts-expect-error code-splitting means it's not defined in client state. - this.state = clientState.reactiveState[ircSlice.reducerPath] as IRCState; - } - } + store.subscribe((value) => { + // @ts-expect-error code-splitting means it's not defined in client state. + this.state = value[ircSlice.reducerPath] as IRCState; }); - $effect(() => { - return this.ircClient.onevent(async (event) => { - return this.handleEvent(event); - }); + this.ircClient.onevent(async (event) => { + return this.handleEvent(event); }); - $effect(() => { - return this.ircClient.onopen(() => { - const channels = this.getChannels(); - this.dispatch(clearNames()); - setTimeout(() => { - for (const channel of channels.current) { - this.send(`JOIN ${channel?.name}`); - } - }, 5000); - }); + this.ircClient.onopen(() => { + const channels = this.getChannels(); + this.dispatch(clearNames()); + setTimeout(() => { + for (const channel of channels.current) { + this.send(`JOIN ${channel?.name}`); + } + }, 5000); }); } diff --git a/apps/desktop/src/lib/selection/uncommittedService.svelte.ts b/apps/desktop/src/lib/selection/uncommittedService.svelte.ts index 8af2803b06..49977d131b 100644 --- a/apps/desktop/src/lib/selection/uncommittedService.svelte.ts +++ b/apps/desktop/src/lib/selection/uncommittedService.svelte.ts @@ -47,10 +47,10 @@ export class UncommittedService { persistReducer(persistConfig, uncommittedSlice.reducer) ); - $effect(() => { - if (clientState.reactiveState && uncommittedSlice.reducerPath in clientState.reactiveState) { + clientState.rootState.subscribe((value) => { + if (value && uncommittedSlice.reducerPath in value) { // @ts-expect-error code-splitting means it's not defined in client state. - this.state = clientState.reactiveState[uncommittedSlice.reducerPath]; + this.state = value[uncommittedSlice.reducerPath]; } }); } diff --git a/apps/desktop/src/lib/state/clientState.svelte.ts b/apps/desktop/src/lib/state/clientState.svelte.ts index a283591702..c582646510 100644 --- a/apps/desktop/src/lib/state/clientState.svelte.ts +++ b/apps/desktop/src/lib/state/clientState.svelte.ts @@ -2,12 +2,12 @@ import { tauriBaseQuery } from '$lib/state/backendQuery'; import { butlerModule } from '$lib/state/butlerModule'; import { ReduxTag } from '$lib/state/tags'; import { uiStateSlice } from '$lib/state/uiState.svelte'; -import { mergeUnlisten } from '@gitbutler/ui/utils/mergeUnlisten'; import { combineSlices, configureStore, type Reducer } from '@reduxjs/toolkit'; import { buildCreateApi, coreModule, setupListeners, type RootState } from '@reduxjs/toolkit/query'; import { FLUSH, PAUSE, PERSIST, persistReducer, PURGE, REGISTER, REHYDRATE } from 'redux-persist'; import persistStore from 'redux-persist/lib/persistStore'; import storage from 'redux-persist/lib/storage'; +import { derived, writable, type Readable } from 'svelte/store'; import type { PostHogWrapper } from '$lib/analytics/posthog'; import type { Tauri } from '$lib/backend/tauri'; import type { GitHubClient } from '$lib/forge/github/githubClient'; @@ -43,8 +43,8 @@ export class ClientState { // $state requires field declaration, but we have to assign the initial // value in the constructor such that we can inject dependencies. The // incorrect casting `as` seems difficult to avoid. - rootState = $state.raw({} as ReturnType); - readonly uiState = $derived(this.rootState.uiState); + readonly rootState = writable({} as ReturnType); + readonly uiState = derived(this.rootState, (value) => value.uiState); /** rtk-query api for communicating with the back end. */ readonly backendApi: BackendApi; @@ -55,10 +55,6 @@ export class ClientState { /** rtk-query api for communicating with GitLab. */ readonly gitlabApi: GitLabApi; - get reactiveState() { - return this.rootState; - } - constructor( tauri: Tauri, gitHubClient: GitHubClient, @@ -69,7 +65,7 @@ export class ClientState { const butlerMod = butlerModule({ // Reactive loop without nested function. // TODO: Can it be done without nesting? - getState: () => () => this.rootState as any as RootState, + store: this.rootState as any as Readable>, getDispatch: () => this.dispatch, posthog }); @@ -91,16 +87,13 @@ export class ClientState { this.reducer = reducer; setupListeners(this.store.dispatch); this.dispatch = this.store.dispatch; - this.rootState = this.store.getState(); - - $effect(() => - mergeUnlisten( - this.store.subscribe(() => { - this.rootState = this.store.getState(); - }), - setupListeners(this.store.dispatch) - ) - ); + this.rootState.set(this.store.getState()); + + this.store.subscribe(() => { + this.rootState.set(this.store.getState()); + }); + + setupListeners(this.store.dispatch); } inject(reducerPath: string, reducer: Reducer) { diff --git a/apps/desktop/src/lib/state/context.ts b/apps/desktop/src/lib/state/context.ts index 04387c62b5..8d504006bd 100644 --- a/apps/desktop/src/lib/state/context.ts +++ b/apps/desktop/src/lib/state/context.ts @@ -1,6 +1,7 @@ import type { PostHogWrapper } from '$lib/analytics/posthog'; import type { EntityState, ThunkDispatch, UnknownAction } from '@reduxjs/toolkit'; import type { CombinedState } from '@reduxjs/toolkit/query'; +import type { Readable } from 'svelte/store'; /** * The api is necessary to create the store, so we need to provide @@ -11,7 +12,7 @@ import type { CombinedState } from '@reduxjs/toolkit/query'; */ export type HookContext = { /** Without the nested function we get looping reactivity. */ - getState: () => () => { [k: string]: CombinedState | EntityState }; + store: Readable<{ [k: string]: CombinedState | EntityState }>; getDispatch: () => ThunkDispatch; posthog?: PostHogWrapper; }; diff --git a/apps/desktop/src/lib/state/customHooks.svelte.ts b/apps/desktop/src/lib/state/customHooks.svelte.ts index cb0a135c4a..bf50913064 100644 --- a/apps/desktop/src/lib/state/customHooks.svelte.ts +++ b/apps/desktop/src/lib/state/customHooks.svelte.ts @@ -37,14 +37,18 @@ const EVENT_NAME = 'tauri_command'; export function buildQueryHooks({ api, endpointName, - ctx: { getState, getDispatch } + ctx: { store, getDispatch } }: { api: Api; endpointName: string; ctx: HookContext; }) { const endpoint = api.endpoints[endpointName]!; - const state = getState() as any as () => RootState; + let state = $state.raw>({}); + + store.subscribe((value) => { + state = value as RootState; + }); const { initiate, select } = endpoint as ApiEndpointQuery, Definitions>; @@ -94,7 +98,7 @@ export function buildQueryHooks({ } const selector = $derived(select(queryArg)); - const result = $derived(selector(state())); + const result = $derived(selector(state)); const output = $derived.by(() => { let data = result.data; if (options?.transform && data) { @@ -133,7 +137,7 @@ export function buildQueryHooks({ const results = queryArgs.map((queryArg) => { const selector = $derived(select(queryArg)); - const result = $derived(selector(state())); + const result = $derived(selector(state)); const output = $derived.by(() => { let data = result.data; if (options?.transform && data) { @@ -151,7 +155,7 @@ export function buildQueryHooks({ function useQueryState(queryArg: unknown, options?: { transform?: T }) { const selector = $derived(select(queryArg)); - const result = $derived(selector(state())); + const result = $derived(selector(state)); const output = $derived.by(() => { let data = result.data; if (options?.transform && data) { @@ -167,7 +171,7 @@ export function buildQueryHooks({ function useQueryTimeStamp(queryArg: unknown) { const selector = $derived(select(queryArg)); - const result = $derived(selector(state())); + const result = $derived(selector(state)); return reactive(() => result.startedTimeStamp); } @@ -283,7 +287,7 @@ export function buildMutationHook< endpointName, actionName, command, - ctx: { getState, getDispatch, posthog } + ctx: { store: getState, getDispatch, posthog } }: { api: Api; endpointName: string; @@ -292,7 +296,10 @@ export function buildMutationHook< ctx: HookContext; }): MutationHook { const endpoint = api.endpoints[endpointName]!; - const state = getState() as any as () => RootState; + let state = $state.raw>({}); + getState.subscribe((value) => { + state = value as RootState; + }); const { initiate, select } = endpoint as unknown as ApiEndpointMutation; @@ -398,7 +405,7 @@ export function buildMutationHook< } const selector = $derived(select({ requestId: promise?.requestId, fixedCacheKey })); - const result = $derived(selector(state())); + const result = $derived(selector(state)); $effect(() => { return () => { diff --git a/apps/desktop/src/lib/state/uiState.svelte.ts b/apps/desktop/src/lib/state/uiState.svelte.ts index aeadf36b48..10170fde52 100644 --- a/apps/desktop/src/lib/state/uiState.svelte.ts +++ b/apps/desktop/src/lib/state/uiState.svelte.ts @@ -9,6 +9,7 @@ import { type UnknownAction } from '@reduxjs/toolkit'; import type { RejectionReason } from '$lib/stacks/stackService.svelte'; +import type { Readable } from 'svelte/store'; export type StackSelection = { branchName: string; @@ -138,11 +139,11 @@ export class UiState { }); constructor( - reactiveState: Reactive, + store: Readable, private dispatch: ThunkDispatch ) { - $effect(() => { - this.state = reactiveState.current; + store.subscribe((value) => { + this.state = value; }); } diff --git a/apps/desktop/src/lib/testing/mockGitHubApi.svelte.ts b/apps/desktop/src/lib/testing/mockGitHubApi.svelte.ts index 7d65c3068c..c42dba22c5 100644 --- a/apps/desktop/src/lib/testing/mockGitHubApi.svelte.ts +++ b/apps/desktop/src/lib/testing/mockGitHubApi.svelte.ts @@ -4,6 +4,7 @@ import { butlerModule } from '$lib/state/butlerModule'; import { createGitHubApi } from '$lib/state/clientState.svelte'; import { Octokit } from '@octokit/rest'; import { configureStore, type ThunkDispatch, type UnknownAction } from '@reduxjs/toolkit'; +import { readable } from 'svelte/store'; /** * Mock for GitHub RTKQ. @@ -31,7 +32,7 @@ export function setupMockGitHubApi() { const gitHubClient = new GitHubClient({ client: octokit }); gitHubClient.setRepo({ owner: 'test-owner', repo: 'test-repo' }); const gitHubApi = createGitHubApi( - butlerModule({ getDispatch: () => dispatch!, getState: () => () => state }) + butlerModule({ getDispatch: () => dispatch!, store: readable(state) }) ); const store = configureStore({ diff --git a/apps/desktop/src/routes/+layout.svelte b/apps/desktop/src/routes/+layout.svelte index 30317757b5..c083eca57c 100644 --- a/apps/desktop/src/routes/+layout.svelte +++ b/apps/desktop/src/routes/+layout.svelte @@ -78,7 +78,6 @@ import { ProjectService as CloudProjectService } from '@gitbutler/shared/organizations/projectService'; import { RepositoryIdLookupService } from '@gitbutler/shared/organizations/repositoryIdLookupService'; import { PatchCommitService as CloudPatchCommitService } from '@gitbutler/shared/patches/patchCommitService'; - import { reactive } from '@gitbutler/shared/reactiveUtils.svelte'; import { AppDispatch, AppState } from '@gitbutler/shared/redux/store.svelte'; import { WebRoutesService } from '@gitbutler/shared/routing/webRoutes.svelte'; import { UploadsService } from '@gitbutler/shared/uploads/uploadsService'; @@ -142,11 +141,7 @@ projectMetrics: data.projectMetrics }); - const uiStateSlice = $derived(clientState.uiState); - const uiState = new UiState( - reactive(() => uiStateSlice), - clientState.dispatch - ); + const uiState = new UiState(clientState.uiState, clientState.dispatch); setContext(UiState, uiState); const intelligentScrollingService = new IntelligentScrollingService(uiState); setContext(IntelligentScrollingService, intelligentScrollingService); From 00f80868b7be9a871b2d3f8909d86b7c546aaf73 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Fri, 11 Jul 2025 19:29:06 +0200 Subject: [PATCH 2/3] Remove some obstacles to running app in a browser This further enables component testing with Cypress. --- apps/desktop/src/lib/analytics/analytics.ts | 3 ++- apps/desktop/src/lib/analytics/posthog.ts | 5 ++--- apps/desktop/src/lib/backend/tauri.ts | 2 ++ apps/desktop/src/lib/platform/platform.ts | 3 ++- apps/desktop/src/lib/utils/theme.ts | 8 +++++--- apps/desktop/src/routes/+layout.ts | 6 ++++-- 6 files changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/lib/analytics/analytics.ts b/apps/desktop/src/lib/analytics/analytics.ts index 3f838f1cbb..1e4c1bbcc5 100644 --- a/apps/desktop/src/lib/analytics/analytics.ts +++ b/apps/desktop/src/lib/analytics/analytics.ts @@ -3,6 +3,7 @@ import { initSentry } from '$lib/analytics/sentry'; import { AppSettings } from '$lib/config/appSettings'; import { getName, getVersion } from '@tauri-apps/api/app'; import posthog from 'posthog-js'; +import { PUBLIC_POSTHOG_API_KEY } from '$env/static/public'; export function initAnalyticsIfEnabled(appSettings: AppSettings, postHog: PostHogWrapper) { if (import.meta.env.MODE === 'development') return; @@ -15,7 +16,7 @@ export function initAnalyticsIfEnabled(appSettings: AppSettings, postHog: PostHo appSettings.appMetricsEnabled.onDisk().then(async (enabled) => { if (enabled) { const [appName, appVersion] = await Promise.all([getName(), getVersion()]); - postHog.init(appName, appVersion); + postHog.init(appName, appVersion, PUBLIC_POSTHOG_API_KEY); } }); appSettings.appNonAnonMetricsEnabled.onDisk().then((enabled) => { diff --git a/apps/desktop/src/lib/analytics/posthog.ts b/apps/desktop/src/lib/analytics/posthog.ts index 105fd1dda4..726c4cd2ec 100644 --- a/apps/desktop/src/lib/analytics/posthog.ts +++ b/apps/desktop/src/lib/analytics/posthog.ts @@ -2,7 +2,6 @@ import { PostHog, posthog, type Properties } from 'posthog-js'; import type { EventContext } from '$lib/analytics/eventContext'; import type { SettingsService } from '$lib/config/appSettingsV2'; import type { RepoInfo } from '$lib/url/gitUrl'; -import { PUBLIC_POSTHOG_API_KEY } from '$env/static/public'; export class PostHogWrapper { private _instance: PostHog | void = undefined; @@ -18,8 +17,8 @@ export class PostHogWrapper { this._instance?.capture(eventName, newProperties); } - async init(appName: string, appVersion: string) { - this._instance = posthog.init(PUBLIC_POSTHOG_API_KEY, { + async init(appName: string, appVersion: string, apiKey: string) { + this._instance = posthog.init(apiKey, { api_host: 'https://eu.posthog.com', autocapture: false, disable_session_recording: true, diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index df857c60c2..9ad12a9e81 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -2,6 +2,8 @@ import { invoke as invokeIpc, listen as listenIpc } from '$lib/backend/ipc'; import { getVersion } from '@tauri-apps/api/app'; import { check } from '@tauri-apps/plugin-updater'; +export const IS_TAURI_ENV = '__TAURI_INTERNALS__' in window; + export class Tauri { invoke = invokeIpc; listen = listenIpc; diff --git a/apps/desktop/src/lib/platform/platform.ts b/apps/desktop/src/lib/platform/platform.ts index 8d52f72c9f..c6f9cf1d4b 100644 --- a/apps/desktop/src/lib/platform/platform.ts +++ b/apps/desktop/src/lib/platform/platform.ts @@ -1,3 +1,4 @@ +import { IS_TAURI_ENV } from '$lib/backend/tauri'; import { platform } from '@tauri-apps/plugin-os'; -export const platformName = platform(); +export const platformName = IS_TAURI_ENV ? platform() : undefined; diff --git a/apps/desktop/src/lib/utils/theme.ts b/apps/desktop/src/lib/utils/theme.ts index 45bfe297ff..2518bb38f9 100644 --- a/apps/desktop/src/lib/utils/theme.ts +++ b/apps/desktop/src/lib/utils/theme.ts @@ -1,17 +1,19 @@ +import { IS_TAURI_ENV } from '$lib/backend/tauri'; import { getCurrentWindow, type Theme } from '@tauri-apps/api/window'; import { type Writable } from 'svelte/store'; import type { Settings } from '$lib/settings/userSettings'; -const appWindow = getCurrentWindow(); + +const appWindow = IS_TAURI_ENV ? getCurrentWindow() : undefined; let systemTheme: string | null; let selectedTheme: string | undefined; export function initTheme(userSettings: Writable) { - appWindow.theme().then((value: Theme | null) => { + appWindow?.theme().then((value: Theme | null) => { systemTheme = value; updateDom(); }); - appWindow.onThemeChanged((e) => { + appWindow?.onThemeChanged((e) => { systemTheme = e.payload; updateDom(); }); diff --git a/apps/desktop/src/routes/+layout.ts b/apps/desktop/src/routes/+layout.ts index 0c93eeae98..d8da80491e 100644 --- a/apps/desktop/src/routes/+layout.ts +++ b/apps/desktop/src/routes/+layout.ts @@ -4,7 +4,7 @@ import { initAnalyticsIfEnabled } from '$lib/analytics/analytics'; import { EventContext } from '$lib/analytics/eventContext'; import { PostHogWrapper } from '$lib/analytics/posthog'; import { CommandService } from '$lib/backend/ipc'; -import { Tauri } from '$lib/backend/tauri'; +import { IS_TAURI_ENV, Tauri } from '$lib/backend/tauri'; import { loadAppSettings } from '$lib/config/appSettings'; import { SettingsService } from '$lib/config/appSettingsV2'; import { GitConfigService } from '$lib/config/gitConfigService'; @@ -37,7 +37,9 @@ export const csr = true; export const load: LayoutLoad = async () => { // TODO: Find a workaround to avoid this dynamic import // https://github.com/sveltejs/kit/issues/905 - const defaultPath = await (await import('@tauri-apps/api/path')).homeDir(); + const defaultPath = IS_TAURI_ENV + ? await (await import('@tauri-apps/api/path')).homeDir() + : undefined; const commandService = new CommandService(); From 0df8f3f4efd8944617a6d076f5e6f57b485bac93 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Fri, 11 Jul 2025 19:31:11 +0200 Subject: [PATCH 3/3] Add a Cypress component test for commit message editor --- apps/desktop/cypress.config.ts | 24 +- .../cypress/component/MesageEditor.cy.ts | 38 ++ apps/desktop/cypress/support/commands.ts | 49 ++ apps/desktop/cypress/support/component.ts | 36 ++ apps/desktop/cypress/support/index.html | 12 + apps/desktop/cypress/tsconfig.json | 2 +- .../components/v3/editor/MessageEditor.svelte | 10 +- apps/desktop/src/lib/testing/testIds.ts | 3 +- apps/desktop/tsconfig.json | 1 - packages/ui/src/styles/utility/layout.min.css | 564 +++++++++++++++++- 10 files changed, 725 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/cypress/component/MesageEditor.cy.ts create mode 100644 apps/desktop/cypress/support/commands.ts create mode 100644 apps/desktop/cypress/support/component.ts create mode 100644 apps/desktop/cypress/support/index.html diff --git a/apps/desktop/cypress.config.ts b/apps/desktop/cypress.config.ts index b6e68a4c5e..9b0e4076bf 100644 --- a/apps/desktop/cypress.config.ts +++ b/apps/desktop/cypress.config.ts @@ -1,4 +1,6 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; import { defineConfig } from 'cypress'; +import path from 'path'; export default defineConfig({ retries: { @@ -7,9 +9,29 @@ export default defineConfig({ // Configure retry attempts for `cypress open` openMode: 0 }, + e2e: { baseUrl: 'http://localhost:1420', supportFile: 'cypress/e2e/support/index.ts' }, - experimentalWebKitSupport: true + + experimentalWebKitSupport: true, + + component: { + devServer: { + framework: 'svelte', + bundler: 'vite', + viteConfig: { + plugins: [svelte()], + resolve: { + alias: { + $components: path.resolve('src/components'), + $lib: path.resolve('src/lib') + } + } + } + }, + // 👇 And this line if Cypress still fails to resolve the iframe mount file + indexHtmlFile: 'cypress/support/index.html' + } }); diff --git a/apps/desktop/cypress/component/MesageEditor.cy.ts b/apps/desktop/cypress/component/MesageEditor.cy.ts new file mode 100644 index 0000000000..80f2a4195b --- /dev/null +++ b/apps/desktop/cypress/component/MesageEditor.cy.ts @@ -0,0 +1,38 @@ +import MessageEditor from '$components/v3/editor/MessageEditor.svelte'; +import { SETTINGS, type Settings } from '$lib/settings/userSettings'; +import { UiState } from '$lib/state/uiState.svelte'; +import { TestId } from '$lib/testing/testIds'; +import { HttpClient } from '@gitbutler/shared/network/httpClient'; +import { UploadsService } from '@gitbutler/shared/uploads/uploadsService'; +import { readable, writable } from 'svelte/store'; +import '../../src/styles/styles.css'; +import '@gitbutler/ui/main.css'; + +describe('CommitMesageEditor.cy.ts', () => { + const httpClient = new HttpClient(window.fetch, 'https://www.example.com', writable('')); + const settings = writable({} as Settings); + it('playground', () => { + const context = new Map(); + const uiState = new UiState(readable({ ids: [], entities: {} }), () => {}); + context.set(UiState, uiState); + context.set(UploadsService, new UploadsService(httpClient)); + context.set(SETTINGS, settings); + + const mountResult = cy.mount(MessageEditor, { + props: { + projectId: '1234', + initialValue: 'Hello world!', + placeholder: 'text goes here', + testId: TestId.EditCommitMessageBox + } as const, + context + }); + mountResult + .then(async ({ component }) => { + const comp = component as MessageEditor; + return await comp.getPlaintext(); + }) + .should('eq', 'Hello world!'); + cy.getByTestId(TestId.EditCommitMessageBox).should('exist').click().type('new text!'); + }); +}); diff --git a/apps/desktop/cypress/support/commands.ts b/apps/desktop/cypress/support/commands.ts new file mode 100644 index 0000000000..26ff8270bd --- /dev/null +++ b/apps/desktop/cypress/support/commands.ts @@ -0,0 +1,49 @@ +/// + +import type { TestId } from '$lib/testing/testIds'; + +// *********************************************** +// This example commands.ts shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +// +// declare global { +// namespace Cypress { +// interface Chainable { +// login(email: string, password: string): Chainable +// drag(subject: string, options?: Partial): Chainable +// dismiss(subject: string, options?: Partial): Chainable +// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable +// } +// } +// } + +type TestIdValues = `${TestId}`; + +Cypress.Commands.add('getByTestId', (testId: TestIdValues, containingText?: string) => { + if (containingText) { + return cy.contains(`[data-testid="${testId}"]`, containingText, { timeout: 15000 }); + } + return cy.get(`[data-testid="${testId}"]`, { timeout: 15000 }); +}); diff --git a/apps/desktop/cypress/support/component.ts b/apps/desktop/cypress/support/component.ts new file mode 100644 index 0000000000..0230a656ef --- /dev/null +++ b/apps/desktop/cypress/support/component.ts @@ -0,0 +1,36 @@ +// *********************************************************** +// This example support/component.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands'; + +import { mount } from 'cypress/svelte'; + +// Augment the Cypress namespace to include type definitions for +// your custom command. +// Alternatively, can be defined in cypress/support/component.d.ts +// with a at the top of your spec. +declare global { + namespace Cypress { + interface Chainable { + mount: typeof mount; + } + } +} + +Cypress.Commands.add('mount', mount); + +// Example use: +// cy.mount(MyComponent) diff --git a/apps/desktop/cypress/support/index.html b/apps/desktop/cypress/support/index.html new file mode 100644 index 0000000000..ac6e79fd83 --- /dev/null +++ b/apps/desktop/cypress/support/index.html @@ -0,0 +1,12 @@ + + + + + + + Components App + + +
+ + \ No newline at end of file diff --git a/apps/desktop/cypress/tsconfig.json b/apps/desktop/cypress/tsconfig.json index 43681fb925..b8cdcdcbd3 100644 --- a/apps/desktop/cypress/tsconfig.json +++ b/apps/desktop/cypress/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["cypress"] }, - "include": ["e2e/**/*.ts"] + "include": ["**/*.ts"] } diff --git a/apps/desktop/src/components/v3/editor/MessageEditor.svelte b/apps/desktop/src/components/v3/editor/MessageEditor.svelte index 3db67dce98..a804d7efe2 100644 --- a/apps/desktop/src/components/v3/editor/MessageEditor.svelte +++ b/apps/desktop/src/components/v3/editor/MessageEditor.svelte @@ -15,6 +15,7 @@ import { showError } from '$lib/notifications/toasts'; import { SETTINGS, type Settings } from '$lib/settings/userSettings'; import { UiState } from '$lib/state/uiState.svelte'; + import { TestId } from '$lib/testing/testIds'; import { getContext, getContextStoreBySymbol } from '@gitbutler/shared/context'; import { uploadFiles } from '@gitbutler/shared/dom'; import { persisted } from '@gitbutler/shared/persisted'; @@ -54,9 +55,9 @@ enableSmiles?: boolean; enableRichText?: boolean; enableRuler?: boolean; - onAiButtonClick: (params: AiButtonClickParams) => void; - canUseAI: boolean; - aiIsLoading: boolean; + onAiButtonClick?: (params: AiButtonClickParams) => void; + canUseAI?: boolean; + aiIsLoading?: boolean; suggestionsHandler?: CommitSuggestions; testId?: string; } @@ -219,7 +220,7 @@ function handleGenerateMessage() { if (aiIsLoading) return; - onAiButtonClick({ + onAiButtonClick?.({ useEmojiStyle: $commitGenerationUseEmojis, useBriefStyle: $commitGenerationExtraConcise }); @@ -385,6 +386,7 @@ onclick={() => { useFloatingBox.current = !useFloatingBox.current; }} + testId={TestId.FloatingModeButton} />
{#if enableSmiles} diff --git a/apps/desktop/src/lib/testing/testIds.ts b/apps/desktop/src/lib/testing/testIds.ts index 552dd0a128..f31ccbb037 100644 --- a/apps/desktop/src/lib/testing/testIds.ts +++ b/apps/desktop/src/lib/testing/testIds.ts @@ -104,7 +104,8 @@ export enum TestId { StackSelectionView = 'stack-selection-view', BranchesSelectionView = 'branches-selection-view', WorkspaceSelectionView = 'workspace-selection-view', - ProjectNotFoundPage = 'project-not-found-page' + ProjectNotFoundPage = 'project-not-found-page', + FloatingModeButton = 'floating-mode-button' } export enum ElementId { diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index b77455bae3..37cae037a8 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -30,6 +30,5 @@ "src/**/*.ts", "src/**/*.svelte", "cypress.config.ts", - "cypress/**/*.ts" ] } diff --git a/packages/ui/src/styles/utility/layout.min.css b/packages/ui/src/styles/utility/layout.min.css index 65da54c768..2a6c487429 100644 --- a/packages/ui/src/styles/utility/layout.min.css +++ b/packages/ui/src/styles/utility/layout.min.css @@ -1,11 +1,566 @@ /* Spasing */ -.gap-2{gap:2px}.p-2{padding:2px}.p-left-2{padding-left:2px}.p-right-2{padding-right:2px}.p-top-2{padding-top:2px}.p-bottom-2{padding-bottom:2px}.m-2{margin:2px}.m-left-2{margin-left:2px}.m-right-2{margin-right:2px}.m-top-2{margin-top:2px}.m-bottom-2{margin-bottom:2px}.gap-4{gap:4px}.p-4{padding:4px}.p-left-4{padding-left:4px}.p-right-4{padding-right:4px}.p-top-4{padding-top:4px}.p-bottom-4{padding-bottom:4px}.m-4{margin:4px}.m-left-4{margin-left:4px}.m-right-4{margin-right:4px}.m-top-4{margin-top:4px}.m-bottom-4{margin-bottom:4px}.gap-6{gap:6px}.p-6{padding:6px}.p-left-6{padding-left:6px}.p-right-6{padding-right:6px}.p-top-6{padding-top:6px}.p-bottom-6{padding-bottom:6px}.m-6{margin:6px}.m-left-6{margin-left:6px}.m-right-6{margin-right:6px}.m-top-6{margin-top:6px}.m-bottom-6{margin-bottom:6px}.gap-8{gap:8px}.p-8{padding:8px}.p-left-8{padding-left:8px}.p-right-8{padding-right:8px}.p-top-8{padding-top:8px}.p-bottom-8{padding-bottom:8px}.m-8{margin:8px}.m-left-8{margin-left:8px}.m-right-8{margin-right:8px}.m-top-8{margin-top:8px}.m-bottom-8{margin-bottom:8px}.gap-10{gap:10px}.p-10{padding:10px}.p-left-10{padding-left:10px}.p-right-10{padding-right:10px}.p-top-10{padding-top:10px}.p-bottom-10{padding-bottom:10px}.m-10{margin:10px}.m-left-10{margin-left:10px}.m-right-10{margin-right:10px}.m-top-10{margin-top:10px}.m-bottom-10{margin-bottom:10px}.gap-12{gap:12px}.p-12{padding:12px}.p-left-12{padding-left:12px}.p-right-12{padding-right:12px}.p-top-12{padding-top:12px}.p-bottom-12{padding-bottom:12px}.m-12{margin:12px}.m-left-12{margin-left:12px}.m-right-12{margin-right:12px}.m-top-12{margin-top:12px}.m-bottom-12{margin-bottom:12px}.gap-14{gap:14px}.p-14{padding:14px}.p-left-14{padding-left:14px}.p-right-14{padding-right:14px}.p-top-14{padding-top:14px}.p-bottom-14{padding-bottom:14px}.m-14{margin:14px}.m-left-14{margin-left:14px}.m-right-14{margin-right:14px}.m-top-14{margin-top:14px}.m-bottom-14{margin-bottom:14px}.gap-16{gap:16px}.p-16{padding:16px}.p-left-16{padding-left:16px}.p-right-16{padding-right:16px}.p-top-16{padding-top:16px}.p-bottom-16{padding-bottom:16px}.m-16{margin:16px}.m-left-16{margin-left:16px}.m-right-16{margin-right:16px}.m-top-16{margin-top:16px}.m-bottom-16{margin-bottom:16px}.gap-20{gap:20px}.p-20{padding:20px}.p-left-20{padding-left:20px}.p-right-20{padding-right:20px}.p-top-20{padding-top:20px}.p-bottom-20{padding-bottom:20px}.m-20{margin:20px}.m-left-20{margin-left:20px}.m-right-20{margin-right:20px}.m-top-20{margin-top:20px}.m-bottom-20{margin-bottom:20px}.gap-24{gap:24px}.p-24{padding:24px}.p-left-24{padding-left:24px}.p-right-24{padding-right:24px}.p-top-24{padding-top:24px}.p-bottom-24{padding-bottom:24px}.m-24{margin:24px}.m-left-24{margin-left:24px}.m-right-24{margin-right:24px}.m-top-24{margin-top:24px}.m-bottom-24{margin-bottom:24px}.gap-28{gap:28px}.p-28{padding:28px}.p-left-28{padding-left:28px}.p-right-28{padding-right:28px}.p-top-28{padding-top:28px}.p-bottom-28{padding-bottom:28px}.m-28{margin:28px}.m-left-28{margin-left:28px}.m-right-28{margin-right:28px}.m-top-28{margin-top:28px}.m-bottom-28{margin-bottom:28px}.gap-32{gap:32px}.p-32{padding:32px}.p-left-32{padding-left:32px}.p-right-32{padding-right:32px}.p-top-32{padding-top:32px}.p-bottom-32{padding-bottom:32px}.m-32{margin:32px}.m-left-32{margin-left:32px}.m-right-32{margin-right:32px}.m-top-32{margin-top:32px}.m-bottom-32{margin-bottom:32px}.gap-36{gap:36px}.p-36{padding:36px}.p-left-36{padding-left:36px}.p-right-36{padding-right:36px}.p-top-36{padding-top:36px}.p-bottom-36{padding-bottom:36px}.m-36{margin:36px}.m-left-36{margin-left:36px}.m-right-36{margin-right:36px}.m-top-36{margin-top:36px}.m-bottom-36{margin-bottom:36px}.gap-40{gap:40px}.p-40{padding:40px}.p-left-40{padding-left:40px}.p-right-40{padding-right:40px}.p-top-40{padding-top:40px}.p-bottom-40{padding-bottom:40px}.m-40{margin:40px}.m-left-40{margin-left:40px}.m-right-40{margin-right:40px}.m-top-40{margin-top:40px}.m-bottom-40{margin-bottom:40px}.gap-44{gap:44px}.p-44{padding:44px}.p-left-44{padding-left:44px}.p-right-44{padding-right:44px}.p-top-44{padding-top:44px}.p-bottom-44{padding-bottom:44px}.m-44{margin:44px}.m-left-44{margin-left:44px}.m-right-44{margin-right:44px}.m-top-44{margin-top:44px}.m-bottom-44{margin-bottom:44px}.gap-48{gap:48px}.p-48{padding:48px}.p-left-48{padding-left:48px}.p-right-48{padding-right:48px}.p-top-48{padding-top:48px}.p-bottom-48{padding-bottom:48px}.m-48{margin:48px}.m-left-48{margin-left:48px}.m-right-48{margin-right:48px}.m-top-48{margin-top:48px}.m-bottom-48{margin-bottom:48px} +.gap-2 { + gap: 2px; +} +.p-2 { + padding: 2px; +} +.p-left-2 { + padding-left: 2px; +} +.p-right-2 { + padding-right: 2px; +} +.p-top-2 { + padding-top: 2px; +} +.p-bottom-2 { + padding-bottom: 2px; +} +.m-2 { + margin: 2px; +} +.m-left-2 { + margin-left: 2px; +} +.m-right-2 { + margin-right: 2px; +} +.m-top-2 { + margin-top: 2px; +} +.m-bottom-2 { + margin-bottom: 2px; +} +.gap-4 { + gap: 4px; +} +.p-4 { + padding: 4px; +} +.p-left-4 { + padding-left: 4px; +} +.p-right-4 { + padding-right: 4px; +} +.p-top-4 { + padding-top: 4px; +} +.p-bottom-4 { + padding-bottom: 4px; +} +.m-4 { + margin: 4px; +} +.m-left-4 { + margin-left: 4px; +} +.m-right-4 { + margin-right: 4px; +} +.m-top-4 { + margin-top: 4px; +} +.m-bottom-4 { + margin-bottom: 4px; +} +.gap-6 { + gap: 6px; +} +.p-6 { + padding: 6px; +} +.p-left-6 { + padding-left: 6px; +} +.p-right-6 { + padding-right: 6px; +} +.p-top-6 { + padding-top: 6px; +} +.p-bottom-6 { + padding-bottom: 6px; +} +.m-6 { + margin: 6px; +} +.m-left-6 { + margin-left: 6px; +} +.m-right-6 { + margin-right: 6px; +} +.m-top-6 { + margin-top: 6px; +} +.m-bottom-6 { + margin-bottom: 6px; +} +.gap-8 { + gap: 8px; +} +.p-8 { + padding: 8px; +} +.p-left-8 { + padding-left: 8px; +} +.p-right-8 { + padding-right: 8px; +} +.p-top-8 { + padding-top: 8px; +} +.p-bottom-8 { + padding-bottom: 8px; +} +.m-8 { + margin: 8px; +} +.m-left-8 { + margin-left: 8px; +} +.m-right-8 { + margin-right: 8px; +} +.m-top-8 { + margin-top: 8px; +} +.m-bottom-8 { + margin-bottom: 8px; +} +.gap-10 { + gap: 10px; +} +.p-10 { + padding: 10px; +} +.p-left-10 { + padding-left: 10px; +} +.p-right-10 { + padding-right: 10px; +} +.p-top-10 { + padding-top: 10px; +} +.p-bottom-10 { + padding-bottom: 10px; +} +.m-10 { + margin: 10px; +} +.m-left-10 { + margin-left: 10px; +} +.m-right-10 { + margin-right: 10px; +} +.m-top-10 { + margin-top: 10px; +} +.m-bottom-10 { + margin-bottom: 10px; +} +.gap-12 { + gap: 12px; +} +.p-12 { + padding: 12px; +} +.p-left-12 { + padding-left: 12px; +} +.p-right-12 { + padding-right: 12px; +} +.p-top-12 { + padding-top: 12px; +} +.p-bottom-12 { + padding-bottom: 12px; +} +.m-12 { + margin: 12px; +} +.m-left-12 { + margin-left: 12px; +} +.m-right-12 { + margin-right: 12px; +} +.m-top-12 { + margin-top: 12px; +} +.m-bottom-12 { + margin-bottom: 12px; +} +.gap-14 { + gap: 14px; +} +.p-14 { + padding: 14px; +} +.p-left-14 { + padding-left: 14px; +} +.p-right-14 { + padding-right: 14px; +} +.p-top-14 { + padding-top: 14px; +} +.p-bottom-14 { + padding-bottom: 14px; +} +.m-14 { + margin: 14px; +} +.m-left-14 { + margin-left: 14px; +} +.m-right-14 { + margin-right: 14px; +} +.m-top-14 { + margin-top: 14px; +} +.m-bottom-14 { + margin-bottom: 14px; +} +.gap-16 { + gap: 16px; +} +.p-16 { + padding: 16px; +} +.p-left-16 { + padding-left: 16px; +} +.p-right-16 { + padding-right: 16px; +} +.p-top-16 { + padding-top: 16px; +} +.p-bottom-16 { + padding-bottom: 16px; +} +.m-16 { + margin: 16px; +} +.m-left-16 { + margin-left: 16px; +} +.m-right-16 { + margin-right: 16px; +} +.m-top-16 { + margin-top: 16px; +} +.m-bottom-16 { + margin-bottom: 16px; +} +.gap-20 { + gap: 20px; +} +.p-20 { + padding: 20px; +} +.p-left-20 { + padding-left: 20px; +} +.p-right-20 { + padding-right: 20px; +} +.p-top-20 { + padding-top: 20px; +} +.p-bottom-20 { + padding-bottom: 20px; +} +.m-20 { + margin: 20px; +} +.m-left-20 { + margin-left: 20px; +} +.m-right-20 { + margin-right: 20px; +} +.m-top-20 { + margin-top: 20px; +} +.m-bottom-20 { + margin-bottom: 20px; +} +.gap-24 { + gap: 24px; +} +.p-24 { + padding: 24px; +} +.p-left-24 { + padding-left: 24px; +} +.p-right-24 { + padding-right: 24px; +} +.p-top-24 { + padding-top: 24px; +} +.p-bottom-24 { + padding-bottom: 24px; +} +.m-24 { + margin: 24px; +} +.m-left-24 { + margin-left: 24px; +} +.m-right-24 { + margin-right: 24px; +} +.m-top-24 { + margin-top: 24px; +} +.m-bottom-24 { + margin-bottom: 24px; +} +.gap-28 { + gap: 28px; +} +.p-28 { + padding: 28px; +} +.p-left-28 { + padding-left: 28px; +} +.p-right-28 { + padding-right: 28px; +} +.p-top-28 { + padding-top: 28px; +} +.p-bottom-28 { + padding-bottom: 28px; +} +.m-28 { + margin: 28px; +} +.m-left-28 { + margin-left: 28px; +} +.m-right-28 { + margin-right: 28px; +} +.m-top-28 { + margin-top: 28px; +} +.m-bottom-28 { + margin-bottom: 28px; +} +.gap-32 { + gap: 32px; +} +.p-32 { + padding: 32px; +} +.p-left-32 { + padding-left: 32px; +} +.p-right-32 { + padding-right: 32px; +} +.p-top-32 { + padding-top: 32px; +} +.p-bottom-32 { + padding-bottom: 32px; +} +.m-32 { + margin: 32px; +} +.m-left-32 { + margin-left: 32px; +} +.m-right-32 { + margin-right: 32px; +} +.m-top-32 { + margin-top: 32px; +} +.m-bottom-32 { + margin-bottom: 32px; +} +.gap-36 { + gap: 36px; +} +.p-36 { + padding: 36px; +} +.p-left-36 { + padding-left: 36px; +} +.p-right-36 { + padding-right: 36px; +} +.p-top-36 { + padding-top: 36px; +} +.p-bottom-36 { + padding-bottom: 36px; +} +.m-36 { + margin: 36px; +} +.m-left-36 { + margin-left: 36px; +} +.m-right-36 { + margin-right: 36px; +} +.m-top-36 { + margin-top: 36px; +} +.m-bottom-36 { + margin-bottom: 36px; +} +.gap-40 { + gap: 40px; +} +.p-40 { + padding: 40px; +} +.p-left-40 { + padding-left: 40px; +} +.p-right-40 { + padding-right: 40px; +} +.p-top-40 { + padding-top: 40px; +} +.p-bottom-40 { + padding-bottom: 40px; +} +.m-40 { + margin: 40px; +} +.m-left-40 { + margin-left: 40px; +} +.m-right-40 { + margin-right: 40px; +} +.m-top-40 { + margin-top: 40px; +} +.m-bottom-40 { + margin-bottom: 40px; +} +.gap-44 { + gap: 44px; +} +.p-44 { + padding: 44px; +} +.p-left-44 { + padding-left: 44px; +} +.p-right-44 { + padding-right: 44px; +} +.p-top-44 { + padding-top: 44px; +} +.p-bottom-44 { + padding-bottom: 44px; +} +.m-44 { + margin: 44px; +} +.m-left-44 { + margin-left: 44px; +} +.m-right-44 { + margin-right: 44px; +} +.m-top-44 { + margin-top: 44px; +} +.m-bottom-44 { + margin-bottom: 44px; +} +.gap-48 { + gap: 48px; +} +.p-48 { + padding: 48px; +} +.p-left-48 { + padding-left: 48px; +} +.p-right-48 { + padding-right: 48px; +} +.p-top-48 { + padding-top: 48px; +} +.p-bottom-48 { + padding-bottom: 48px; +} +.m-48 { + margin: 48px; +} +.m-left-48 { + margin-left: 48px; +} +.m-right-48 { + margin-right: 48px; +} +.m-top-48 { + margin-top: 48px; +} +.m-bottom-48 { + margin-bottom: 48px; +} /* Position */ -.relative { position: relative; } .absolute { position: absolute; } .fixed { position: fixed; } .sticky { position: sticky; } .top-0 { top: 0; } .bottom-0 { bottom: 0; } .left-0 { left: 0; } .right-0 { right: 0; } +.relative { + position: relative; +} +.absolute { + position: absolute; +} +.fixed { + position: fixed; +} +.sticky { + position: sticky; +} +.top-0 { + top: 0; +} +.bottom-0 { + bottom: 0; +} +.left-0 { + left: 0; +} +.right-0 { + right: 0; +} /* Size */ -.full-width { width: 100%; } .full-height { height: 100%; } +.full-width { + width: 100%; +} +.full-height { + height: 100%; +} /* Flexbox */ .flex { @@ -29,9 +584,6 @@ .full-width { width: 100%; } -.full-height { - height: 100%; -} .flex-1 { flex: 1; }