From 470fbc290bb528bb40dd0f50b06a5dad81c7e7c4 Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 11:06:45 -0500 Subject: [PATCH 01/18] refactor: use GITHUB_GIST_LOAD in fetchGistAndLoad() --- tests/mocks/electron-fiddle.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/mocks/electron-fiddle.ts b/tests/mocks/electron-fiddle.ts index 17059156cb..92b199b195 100644 --- a/tests/mocks/electron-fiddle.ts +++ b/tests/mocks/electron-fiddle.ts @@ -57,4 +57,5 @@ export class ElectronFiddleMock { public themePath = '~/.electron-fiddle/themes'; public uncacheTypes = vi.fn(); public unwatchElectronTypes = vi.fn(); + public gistLoad = vi.fn(); } From 463bd5376770577ce6db87bbfbbb78b04e5f0b07 Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 11:20:44 -0500 Subject: [PATCH 02/18] refactor: use GITHUB_GIST_LIST_COMMITS in getGistRevisions() --- tests/mocks/electron-fiddle.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/mocks/electron-fiddle.ts b/tests/mocks/electron-fiddle.ts index 92b199b195..17059156cb 100644 --- a/tests/mocks/electron-fiddle.ts +++ b/tests/mocks/electron-fiddle.ts @@ -57,5 +57,4 @@ export class ElectronFiddleMock { public themePath = '~/.electron-fiddle/themes'; public uncacheTypes = vi.fn(); public unwatchElectronTypes = vi.fn(); - public gistLoad = vi.fn(); } From 5a2d820e7625eb7acd6facea32a695c81bc48bfe Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 13:39:42 -0500 Subject: [PATCH 03/18] refactor: use GITHUB_GIST_UPDATE in handleUpdate() --- src/ambient.d.ts | 3 ++ src/interfaces.ts | 19 +++++++++-- src/main/github.ts | 34 ++++++------------- src/preload/preload.ts | 3 ++ .../components/commands-action-button.tsx | 20 +++-------- src/renderer/remote-loader.ts | 5 ++- 6 files changed, 43 insertions(+), 41 deletions(-) diff --git a/src/ambient.d.ts b/src/ambient.d.ts index 3dca546b18..4ea6338ffa 100644 --- a/src/ambient.d.ts +++ b/src/ambient.d.ts @@ -10,6 +10,8 @@ import { GistLoadParams, GistLoadResult, GistRevision, + GistUpdateParams, + GistWriteResult, IPackageManager, InstallState, InstallStateEvent, @@ -112,6 +114,7 @@ declare global { fetchExample(ref: string, path: string): Promise; gistListCommits(gistId: string): Promise; gistLoad(params: GistLoadParams): Promise; + gistUpdate(params: GistUpdateParams): Promise; getAvailableThemes(): Promise>; getElectronTypes(ver: RunnableVersion): Promise; getIsPackageManagerInstalled( diff --git a/src/interfaces.ts b/src/interfaces.ts index 967fde54bf..b9b01ed320 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -220,13 +220,28 @@ export interface GistRevision { } export interface GistLoadParams { - gistId: string; + id: string; revision?: string; } +export interface GistFile { + filename: string; + content: string; +} + export interface GistLoadResult { - files: Record; + files: Record; + revision?: string; +} + +export interface GistUpdateParams { id: string; + files: Record; +} + +export interface GistWriteResult { + id: string; + url: string; revision?: string; } diff --git a/src/main/github.ts b/src/main/github.ts index b9a3e46e16..a2a2625b7c 100644 --- a/src/main/github.ts +++ b/src/main/github.ts @@ -7,7 +7,7 @@ import { IpcMainInvokeEvent, app, safeStorage } from 'electron'; import { getTemplate } from './content'; import { ipcMainManager } from './ipc'; import { GIST_MAX_FILE_COUNT, GIST_MAX_FILE_SIZE } from '../constants'; -import { EditorValues, GistLoadResult, GistRevision } from '../interfaces'; +import { EditorValues, GistFile, GistLoadResult, GistRevision, GistWriteResult } from '../interfaces'; import { IpcEvents } from '../ipc-events'; import { isSupportedFile } from '../utils/editor-utils'; @@ -46,11 +46,6 @@ function isValidDescription(description: unknown): description is string { ); } -interface GistFile { - filename: string; - content: string; -} - function areValidGistFiles( files: unknown, ): files is Record { @@ -201,12 +196,6 @@ async function handleTokenCheckAuth( } } -interface GistWriteResult { - id: string; - url: string; - revision?: string; -} - async function handleGistCreate( _event: IpcMainInvokeEvent, params: unknown, @@ -245,22 +234,22 @@ async function handleGistUpdate( if (typeof params !== 'object' || params === null) throw new Error('Invalid parameters.'); - const { gistId, files } = params as Record; + const { id, files } = params as Record; - if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.'); + if (!isValidGistId(id)) throw new Error('Invalid gist ID.'); if (!areValidGistFiles(files)) throw new Error('Invalid files payload.'); const octo = getAuthenticatedOctokit(); // Fetch existing files to detect deletions - const { data: existing } = await octo.gists.get({ gist_id: gistId }); + const { data: existing } = await octo.gists.get({ gist_id: id }); const updateFiles = { ...(files as Record) }; - for (const id of Object.keys(existing.files ?? {})) { - if (!(id in updateFiles)) updateFiles[id] = null as any; + for (const fileId of Object.keys(existing.files ?? {})) { + if (!(fileId in updateFiles)) updateFiles[fileId] = null as any; } const gist = await octo.gists.update({ - gist_id: gistId, + gist_id: id, files: updateFiles as any, }); @@ -289,16 +278,16 @@ async function handleGistLoad( if (typeof params !== 'object' || params === null) throw new Error('Invalid parameters.'); - const { gistId, revision } = params as Record; + const { id, revision } = params as Record; - if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.'); + if (!isValidGistId(id)) throw new Error('Invalid gist ID.'); if (revision !== undefined && !isValidSha(revision)) throw new Error('Invalid revision SHA.'); const octo = getOctokit(); const gist = revision - ? await octo.gists.getRevision({ gist_id: gistId, sha: revision }) - : await octo.gists.get({ gist_id: gistId }); + ? await octo.gists.getRevision({ gist_id: id, sha: revision }) + : await octo.gists.get({ gist_id: id }); const files: GistLoadResult['files'] = {}; for (const [id, data] of Object.entries(gist.data.files ?? {})) { @@ -322,7 +311,6 @@ async function handleGistLoad( return { files, - id: gist.data.id!, revision: gist.data.history?.[0]?.version, }; } diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 1108986bf9..03e6967fe7 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -8,6 +8,7 @@ import { FileTransformOperation, Files, GistLoadParams, + GistUpdateParams, IPackageManager, MessageOptions, PMOperationOptions, @@ -113,6 +114,8 @@ export async function setupFiddleGlobal() { ipcRenderer.invoke(IpcEvents.GITHUB_GIST_LIST_COMMITS, gistId), gistLoad: (params: GistLoadParams) => ipcRenderer.invoke(IpcEvents.GITHUB_GIST_LOAD, params), + gistUpdate: (params: GistUpdateParams) => + ipcRenderer.invoke(IpcEvents.GITHUB_GIST_UPDATE, params), getElectronTypes(ver: RunnableVersion) { return ipcRenderer.invoke(IpcEvents.GET_ELECTRON_TYPES, ver); }, diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx index a40159293f..7792011f3a 100644 --- a/src/renderer/components/commands-action-button.tsx +++ b/src/renderer/components/commands-action-button.tsx @@ -194,31 +194,22 @@ export const GistActionButton = observer( */ public async handleUpdate(silent = false) { const { appState } = this.props; - const octo = await getOctokit(this.props.appState); const options = { includeDependencies: true, includeElectron: true }; const values = await window.app.getEditorValues(options); appState.activeGistAction = GistActionState.updating; try { - const { - data: { files: oldFiles }, - } = await octo.gists.get({ gist_id: appState.gistId! }); - const files = this.gistFilesList(values); - for (const id of Object.keys(oldFiles ?? {})) { - // Delete files that have been removed or renamed. - if (!(id in files)) files[id] = null as any; - } - const gist = await octo.gists.update({ - gist_id: appState.gistId!, + const gist = await window.ElectronFiddle.gistUpdate({ + id: appState.gistId!, files, }); // Update the active revision to the newly created revision - if (gist.data.history?.[0]?.version) { - appState.activeGistRevision = gist.data.history[0].version; + if (gist.revision) { + appState.activeGistRevision = gist.revision; } await appState.editorMosaic.markAsSaved(); @@ -230,8 +221,7 @@ export const GistActionButton = observer( action: { text: 'Copy link', icon: 'clipboard', - onClick: () => - navigator.clipboard.writeText(gist.data.html_url ?? ''), + onClick: () => navigator.clipboard.writeText(gist.url), }, }); } diff --git a/src/renderer/remote-loader.ts b/src/renderer/remote-loader.ts index 7e96a4d673..c7c225e624 100644 --- a/src/renderer/remote-loader.ts +++ b/src/renderer/remote-loader.ts @@ -94,7 +94,10 @@ export class RemoteLoader { revision?: string, ): Promise { try { - const gist = await window.ElectronFiddle.gistLoad({ gistId, revision }); + const gist = await window.ElectronFiddle.gistLoad({ + id: gistId, + revision, + }); const values: EditorValues = {}; From 29ea37de4dd3896fb133155983725867746db658 Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 13:48:03 -0500 Subject: [PATCH 04/18] refactor: use GITHUB_GIST_DELETE in handleDelete() --- src/ambient.d.ts | 1 + src/main/github.ts | 3 +-- src/preload/preload.ts | 2 ++ src/renderer/components/commands-action-button.tsx | 7 ++----- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/ambient.d.ts b/src/ambient.d.ts index 4ea6338ffa..25c8bcbcc7 100644 --- a/src/ambient.d.ts +++ b/src/ambient.d.ts @@ -115,6 +115,7 @@ declare global { gistListCommits(gistId: string): Promise; gistLoad(params: GistLoadParams): Promise; gistUpdate(params: GistUpdateParams): Promise; + gistDelete(id: string): Promise; getAvailableThemes(): Promise>; getElectronTypes(ver: RunnableVersion): Promise; getIsPackageManagerInstalled( diff --git a/src/main/github.ts b/src/main/github.ts index a2a2625b7c..f377b984e6 100644 --- a/src/main/github.ts +++ b/src/main/github.ts @@ -263,12 +263,11 @@ async function handleGistUpdate( async function handleGistDelete( _event: IpcMainInvokeEvent, gistId: unknown, -): Promise<{ success: boolean }> { +): Promise { if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.'); const octo = getAuthenticatedOctokit(); await octo.gists.delete({ gist_id: gistId }); - return { success: true }; } async function handleGistLoad( diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 03e6967fe7..844ff595b3 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -116,6 +116,8 @@ export async function setupFiddleGlobal() { ipcRenderer.invoke(IpcEvents.GITHUB_GIST_LOAD, params), gistUpdate: (params: GistUpdateParams) => ipcRenderer.invoke(IpcEvents.GITHUB_GIST_UPDATE, params), + gistDelete: (id: string) => + ipcRenderer.invoke(IpcEvents.GITHUB_GIST_DELETE, id), getElectronTypes(ver: RunnableVersion) { return ipcRenderer.invoke(IpcEvents.GET_ELECTRON_TYPES, ver); }, diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx index 7792011f3a..3bcfdd17b0 100644 --- a/src/renderer/components/commands-action-button.tsx +++ b/src/renderer/components/commands-action-button.tsx @@ -245,17 +245,14 @@ export const GistActionButton = observer( */ public async handleDelete() { const { appState } = this.props; - const octo = await getOctokit(this.props.appState); appState.activeGistAction = GistActionState.deleting; try { - const gist = await octo.gists.delete({ - gist_id: appState.gistId!, - }); + await window.ElectronFiddle.gistDelete(appState.gistId!); appState.editorMosaic.clearSaved(); - console.log('Deleting: Deleting done', { gist }); + console.log('Deleting: Deleting done'); this.renderToast({ message: 'Successfully deleted gist!' }); } catch (error: any) { console.warn(`Could not delete gist`, { error }); From 38d58061388c0e14efcefe92d37b760e10cd2223 Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 13:54:56 -0500 Subject: [PATCH 05/18] refactor: use GITHUB_GIST_CREATE in publishGist() --- src/ambient.d.ts | 4 +++- src/interfaces.ts | 12 +++++++++--- src/preload/preload.ts | 3 +++ src/renderer/components/commands-action-button.tsx | 13 +++++-------- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/ambient.d.ts b/src/ambient.d.ts index 25c8bcbcc7..c3a9c20c9f 100644 --- a/src/ambient.d.ts +++ b/src/ambient.d.ts @@ -7,6 +7,7 @@ import { FiddleEvent, FileTransformOperation, Files, + GistCreateParams, GistLoadParams, GistLoadResult, GistRevision, @@ -112,10 +113,11 @@ declare global { ): Promise; fetchVersions(): Promise; fetchExample(ref: string, path: string): Promise; + gistCreate(params: GistCreateParams): Promise; + gistDelete(id: string): Promise; gistListCommits(gistId: string): Promise; gistLoad(params: GistLoadParams): Promise; gistUpdate(params: GistUpdateParams): Promise; - gistDelete(id: string): Promise; getAvailableThemes(): Promise>; getElectronTypes(ver: RunnableVersion): Promise; getIsPackageManagerInstalled( diff --git a/src/interfaces.ts b/src/interfaces.ts index b9b01ed320..eeaa4d9022 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -219,9 +219,10 @@ export interface GistRevision { }; } -export interface GistLoadParams { - id: string; - revision?: string; +export interface GistCreateParams { + description: string; + files: Record; + isPublic: boolean; } export interface GistFile { @@ -229,6 +230,11 @@ export interface GistFile { content: string; } +export interface GistLoadParams { + id: string; + revision?: string; +} + export interface GistLoadResult { files: Record; revision?: string; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 844ff595b3..802a310e5d 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -7,6 +7,7 @@ import { FiddleEvent, FileTransformOperation, Files, + GistCreateParams, GistLoadParams, GistUpdateParams, IPackageManager, @@ -118,6 +119,8 @@ export async function setupFiddleGlobal() { ipcRenderer.invoke(IpcEvents.GITHUB_GIST_UPDATE, params), gistDelete: (id: string) => ipcRenderer.invoke(IpcEvents.GITHUB_GIST_DELETE, id), + gistCreate: (params: GistCreateParams) => + ipcRenderer.invoke(IpcEvents.GITHUB_GIST_CREATE, params), getElectronTypes(ver: RunnableVersion) { return ipcRenderer.invoke(IpcEvents.GET_ELECTRON_TYPES, ver); }, diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx index 3bcfdd17b0..111b158275 100644 --- a/src/renderer/components/commands-action-button.tsx +++ b/src/renderer/components/commands-action-button.tsx @@ -22,7 +22,6 @@ import { } from '../../interfaces'; import { AppState } from '../state'; import { ensureRequiredFiles } from '../utils/editor-utils'; -import { getOctokit } from '../utils/octokit'; interface GistActionButtonProps { appState: AppState; @@ -110,7 +109,6 @@ export const GistActionButton = observer( private async publishGist(description: string): Promise { const { appState } = this.props; - const octo = await getOctokit(appState); const { gitHubPublishAsPublic } = appState; const options = { includeDependencies: true, includeElectron: true }; const defaultGistValues = await window.ElectronFiddle.getTemplate( @@ -126,14 +124,14 @@ export const GistActionButton = observer( ? this.gistFilesList(defaultGistValues) : this.gistFilesList(currentEditorValues); - const gist = await octo.gists.create({ - public: !!gitHubPublishAsPublic, + const gist = await window.ElectronFiddle.gistCreate({ + isPublic: !!gitHubPublishAsPublic, description, files: gistFilesList, }); - appState.gistId = gist.data.id; - appState.activeGistRevision = gist.data.history?.[0]?.version; + appState.gistId = gist.id; + appState.activeGistRevision = gist.revision; appState.localPath = undefined; if (appState.isPublishingGistAsRevision) { @@ -146,8 +144,7 @@ export const GistActionButton = observer( action: { text: 'Copy link', icon: 'clipboard', - onClick: () => - navigator.clipboard.writeText(gist.data.html_url ?? ''), + onClick: () => navigator.clipboard.writeText(gist.url), }, }); From f486c24b43209bca285bc45b85b3e3afd753c1ca Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 14:06:50 -0500 Subject: [PATCH 06/18] refactor: use GITHUB_TOKEN_SIGN_IN in onSubmitToken() refactor: remove octokit from renderer --- src/ambient.d.ts | 2 + src/interfaces.ts | 6 + src/main/github.ts | 10 +- src/preload/preload.ts | 10 +- src/renderer/components/dialog-token.tsx | 64 +----- src/renderer/utils/octokit.ts | 22 -- tests/main/github.spec.ts | 40 ++-- tests/mocks/electron-fiddle.ts | 4 + .../commands-publish-button.spec.tsx | 146 ++++++------- .../renderer/components/dialog-token.spec.tsx | 195 ++---------------- tests/renderer/components/history.spec.tsx | 6 - tests/renderer/remote-loader.spec.ts | 18 +- 12 files changed, 134 insertions(+), 389 deletions(-) delete mode 100644 src/renderer/utils/octokit.ts diff --git a/src/ambient.d.ts b/src/ambient.d.ts index c3a9c20c9f..f49ab8039e 100644 --- a/src/ambient.d.ts +++ b/src/ambient.d.ts @@ -13,6 +13,7 @@ import { GistRevision, GistUpdateParams, GistWriteResult, + GitHubSignInResult, IPackageManager, InstallState, InstallStateEvent, @@ -118,6 +119,7 @@ declare global { gistListCommits(gistId: string): Promise; gistLoad(params: GistLoadParams): Promise; gistUpdate(params: GistUpdateParams): Promise; + gitHubSignIn(token: string): Promise; getAvailableThemes(): Promise>; getElectronTypes(ver: RunnableVersion): Promise; getIsPackageManagerInstalled( diff --git a/src/interfaces.ts b/src/interfaces.ts index eeaa4d9022..c86e9c9e2d 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -251,6 +251,12 @@ export interface GistWriteResult { revision?: string; } +export interface GitHubSignInResult { + success: boolean; + login?: string; + error?: string; +} + export enum GlobalSetting { acceleratorsToBlock = 'acceleratorsToBlock', channelsToShow = 'channelsToShow', diff --git a/src/main/github.ts b/src/main/github.ts index f377b984e6..a561072488 100644 --- a/src/main/github.ts +++ b/src/main/github.ts @@ -7,7 +7,7 @@ import { IpcMainInvokeEvent, app, safeStorage } from 'electron'; import { getTemplate } from './content'; import { ipcMainManager } from './ipc'; import { GIST_MAX_FILE_COUNT, GIST_MAX_FILE_SIZE } from '../constants'; -import { EditorValues, GistFile, GistLoadResult, GistRevision, GistWriteResult } from '../interfaces'; +import { EditorValues, GistFile, GistLoadResult, GistRevision, GistWriteResult, GitHubSignInResult } from '../interfaces'; import { IpcEvents } from '../ipc-events'; import { isSupportedFile } from '../utils/editor-utils'; @@ -118,16 +118,10 @@ function getOctokit(): Octokit { // --- IPC handlers --- -interface SignInResult { - success: boolean; - login?: string; - error?: string; -} - async function handleTokenSignIn( _event: IpcMainInvokeEvent, token: unknown, -): Promise { +): Promise { if (!isValidToken(token)) return { success: false, error: 'Invalid token format.' }; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 802a310e5d..e247e15a66 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -111,16 +111,18 @@ export async function setupFiddleGlobal() { }, fetchExample: (ref: string, path: string) => ipcRenderer.invoke(IpcEvents.GITHUB_FETCH_EXAMPLE, { ref, path }), + gistCreate: (params: GistCreateParams) => + ipcRenderer.invoke(IpcEvents.GITHUB_GIST_CREATE, params), + gistDelete: (id: string) => + ipcRenderer.invoke(IpcEvents.GITHUB_GIST_DELETE, id), gistListCommits: (gistId: string) => ipcRenderer.invoke(IpcEvents.GITHUB_GIST_LIST_COMMITS, gistId), gistLoad: (params: GistLoadParams) => ipcRenderer.invoke(IpcEvents.GITHUB_GIST_LOAD, params), gistUpdate: (params: GistUpdateParams) => ipcRenderer.invoke(IpcEvents.GITHUB_GIST_UPDATE, params), - gistDelete: (id: string) => - ipcRenderer.invoke(IpcEvents.GITHUB_GIST_DELETE, id), - gistCreate: (params: GistCreateParams) => - ipcRenderer.invoke(IpcEvents.GITHUB_GIST_CREATE, params), + gitHubSignIn: (token: string) => + ipcRenderer.invoke(IpcEvents.GITHUB_TOKEN_SIGN_IN, token), getElectronTypes(ver: RunnableVersion) { return ipcRenderer.invoke(IpcEvents.GET_ELECTRON_TYPES, ver); }, diff --git a/src/renderer/components/dialog-token.tsx b/src/renderer/components/dialog-token.tsx index 539b3f806a..f7df63c82d 100644 --- a/src/renderer/components/dialog-token.tsx +++ b/src/renderer/components/dialog-token.tsx @@ -4,7 +4,6 @@ import { Button, Callout, Dialog, InputGroup, Intent } from '@blueprintjs/core'; import { observer } from 'mobx-react'; import { AppState } from '../state'; -import { getOctokit } from '../utils/octokit'; interface TokenDialogProps { appState: AppState; @@ -51,67 +50,24 @@ export const TokenDialog = observer( } /** - * Validates a GitHub token and checks for required scopes. - */ - private async validateGitHubToken(token: string): Promise<{ - isValid: boolean; - scopes: string[]; - hasGistScope: boolean; - user?: any; - error?: string; - }> { - try { - const octokit = await getOctokit({ gitHubToken: token } as AppState); - const response = await octokit.users.getAuthenticated(); - - const scopes = response.headers['x-oauth-scopes']?.split(', ') || []; - const hasGistScope = scopes.includes('gist'); - - return { - isValid: true, - scopes, - hasGistScope, - user: response.data, - }; - } catch (error: any) { - return { - isValid: false, - scopes: [], - hasGistScope: false, - error: error.message, - }; - } - } - - /** - * Handles the submission of a token and verifies - * that it has the correct scopes. + * Handles the submission of a token. Validation runs in the main + * process, which checks the token's format, authenticates against + * GitHub, and verifies that the required scopes are present. */ public async onSubmitToken(): Promise { if (!this.state.tokenInput) return; this.setState({ verifying: true, error: false, errorMessage: undefined }); - const validation = await this.validateGitHubToken(this.state.tokenInput); - - if (!validation.isValid) { - console.warn(`Authenticating against GitHub failed`, validation.error); - this.setState({ - verifying: false, - error: true, - errorMessage: - 'Invalid GitHub token. Please check your token and try again.', - }); - this.props.appState.gitHubToken = null; - return; - } + const result = await window.ElectronFiddle.gitHubSignIn( + this.state.tokenInput, + ); - if (!validation.hasGistScope) { - console.warn(`Token missing required gist scope`); + if (!result.success) { + console.warn(`Authenticating against GitHub failed`, result.error); this.setState({ verifying: false, error: true, - errorMessage: - 'Token is missing the "gist" scope. Please generate a new token with gist permissions.', + errorMessage: result.error, }); this.props.appState.gitHubToken = null; return; @@ -119,7 +75,7 @@ export const TokenDialog = observer( // Token is valid and has required scopes. this.props.appState.gitHubToken = this.state.tokenInput; - this.props.appState.gitHubLogin = validation.user.login; + this.props.appState.gitHubLogin = result.login ?? null; this.setState({ verifying: false, error: false }); this.props.appState.isTokenDialogShowing = false; diff --git a/src/renderer/utils/octokit.ts b/src/renderer/utils/octokit.ts deleted file mode 100644 index eec098a00a..0000000000 --- a/src/renderer/utils/octokit.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Octokit } from '@octokit/rest'; - -import { AppState } from '../../renderer/state'; - -let _octo: Octokit; - -/** - * Returns a loaded Octokit. If state is passed and authentication - * is available, we'll token-authenticate. - */ -export async function getOctokit(appState?: AppState): Promise { - // It's possible to load Gists without being authenticated, - // but we get better rate limits when authenticated. - _octo = - _octo || appState?.gitHubToken - ? new Octokit({ - auth: appState?.gitHubToken, - }) - : new Octokit(); - - return _octo; -} diff --git a/tests/main/github.spec.ts b/tests/main/github.spec.ts index d9245a29ba..e757e1838d 100644 --- a/tests/main/github.spec.ts +++ b/tests/main/github.spec.ts @@ -387,7 +387,7 @@ describe('github', () => { it('updates a gist with valid parameters', async () => { const result = await handleGistUpdate(MOCK_EVENT, { - gistId: VALID_GIST_ID, + id: VALID_GIST_ID, files: { 'main.js': { filename: 'main.js', content: 'new code' }, 'old.js': null, @@ -401,7 +401,7 @@ describe('github', () => { for (const gistId of INVALID_GIST_IDS) { await expect( handleGistUpdate(MOCK_EVENT, { - gistId, + id: gistId, files: VALID_FILES, }), ).rejects.toThrow('Invalid gist ID'); @@ -418,7 +418,7 @@ describe('github', () => { for (const files of invalidFiles) { await expect( handleGistUpdate(MOCK_EVENT, { - gistId: VALID_GIST_ID, + id: VALID_GIST_ID, files, }), ).rejects.toThrow('Invalid files'); @@ -426,7 +426,7 @@ describe('github', () => { await expect( handleGistUpdate(MOCK_EVENT, { - gistId: VALID_GIST_ID, + id: VALID_GIST_ID, files: 'not-an-object', }), ).rejects.toThrow('Invalid files'); @@ -451,8 +451,9 @@ describe('github', () => { VALID_GIST_ID, 'AABBCCDDEE11223344556677889900FF', ]) { - const result = await handleGistDelete(MOCK_EVENT, gistId); - expect(result).toEqual({ success: true }); + await expect( + handleGistDelete(MOCK_EVENT, gistId), + ).resolves.toBeUndefined(); } }); @@ -475,34 +476,33 @@ describe('github', () => { it('loads a gist by ID', async () => { const result = await handleGistLoad(MOCK_EVENT, { - gistId: VALID_GIST_ID, + id: VALID_GIST_ID, }); - expect(result.id).toBe(VALID_GIST_ID); expect(result.files['main.js'].content).toBe('console.log("hi")'); }); it('loads a gist at a specific revision', async () => { const result = await handleGistLoad(MOCK_EVENT, { - gistId: VALID_GIST_ID, + id: VALID_GIST_ID, revision: VALID_SHA, }); - expect(result.id).toBe(VALID_GIST_ID); + expect(result.revision).toBe('sha1'); }); it('rejects invalid gist IDs', async () => { for (const gistId of INVALID_GIST_IDS) { - await expect(handleGistLoad(MOCK_EVENT, { gistId })).rejects.toThrow( - 'Invalid gist ID', - ); + await expect( + handleGistLoad(MOCK_EVENT, { id: gistId }), + ).rejects.toThrow('Invalid gist ID'); } }); it('rejects invalid revision SHA', async () => { await expect( handleGistLoad(MOCK_EVENT, { - gistId: VALID_GIST_ID, + id: VALID_GIST_ID, revision: 'not-a-sha', }), ).rejects.toThrow('Invalid revision SHA'); @@ -511,11 +511,11 @@ describe('github', () => { it('loads a gist with valid or omitted revision', async () => { for (const revision of [VALID_SHA, undefined]) { const result = await handleGistLoad(MOCK_EVENT, { - gistId: VALID_GIST_ID, + id: VALID_GIST_ID, revision, }); - expect(result.id).toBe(VALID_GIST_ID); + expect(result.revision).toBe('sha1'); } }); @@ -523,7 +523,7 @@ describe('github', () => { for (const revision of ['abc123', null, 'a'.repeat(41), 'A'.repeat(40)]) { await expect( handleGistLoad(MOCK_EVENT, { - gistId: VALID_GIST_ID, + id: VALID_GIST_ID, revision, }), ).rejects.toThrow('Invalid revision SHA'); @@ -542,9 +542,9 @@ describe('github', () => { mockOctokitInstance(); const result = await handleGistLoad(MOCK_EVENT, { - gistId: VALID_GIST_ID, + id: VALID_GIST_ID, }); - expect(result.id).toBe(VALID_GIST_ID); + expect(result.files['main.js'].content).toBe('console.log("hi")'); }); it('fetches full content for truncated files', async () => { @@ -579,7 +579,7 @@ describe('github', () => { } as Response); const result = await handleGistLoad(MOCK_EVENT, { - gistId: VALID_GIST_ID, + id: VALID_GIST_ID, }); expect(global.fetch).toHaveBeenCalledWith( diff --git a/tests/mocks/electron-fiddle.ts b/tests/mocks/electron-fiddle.ts index 17059156cb..d84a14c4d4 100644 --- a/tests/mocks/electron-fiddle.ts +++ b/tests/mocks/electron-fiddle.ts @@ -11,8 +11,12 @@ export class ElectronFiddleMock { public downloadVersion = vi.fn(); public fetchExample = vi.fn(); public fetchVersions = vi.fn(); + public gistCreate = vi.fn(); + public gistDelete = vi.fn(); public gistListCommits = vi.fn(); public gistLoad = vi.fn(); + public gistUpdate = vi.fn(); + public gitHubSignIn = vi.fn(); public getAvailableThemes = vi.fn(); public getElectronTypes = vi.fn(); public getIsPackageManagerInstalled = vi.fn(); diff --git a/tests/renderer/components/commands-publish-button.spec.tsx b/tests/renderer/components/commands-publish-button.spec.tsx index d244acedc9..ac8238b1b2 100644 --- a/tests/renderer/components/commands-publish-button.spec.tsx +++ b/tests/renderer/components/commands-publish-button.spec.tsx @@ -1,4 +1,3 @@ -import { Octokit } from '@octokit/rest'; import { act, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -6,59 +5,24 @@ import { EditorValues, GistActionState, GistActionType, + GistCreateParams, + GistFile, MAIN_JS, } from '../../../src/interfaces'; import { App } from '../../../src/renderer/app'; import { GistActionButton } from '../../../src/renderer/components/commands-action-button'; import { AppState } from '../../../src/renderer/state'; -import { getOctokit } from '../../../src/renderer/utils/octokit'; import { createEditorValues } from '../../mocks/mocks'; import { renderClassComponentWithInstanceRef } from '../utils/renderClassComponentWithInstanceRef'; -vi.mock('../../../src/renderer/utils/octokit'); - -class OctokitMock { - private static nextId = 1; - private static nextVersion = 1; - - static resetCounters() { - OctokitMock.nextId = 1; - OctokitMock.nextVersion = 1; - } - - public authenticate = vi.fn(); - public gists = { - create: vi.fn().mockImplementation(() => ({ - data: { - id: OctokitMock.nextId++, - history: [{ version: `created-sha-${OctokitMock.nextVersion++}` }], - }, - })), - delete: vi.fn(), - update: vi.fn().mockImplementation(() => ({ - data: { - history: [{ version: `updated-sha-${OctokitMock.nextVersion++}` }], - }, - })), - get: vi.fn(), - }; -} - -type GistFile = { filename: string; content: string }; type GistFiles = { [id: string]: GistFile }; -type GistCreateOpts = { - description: string; - files: GistFiles; - public: boolean; -}; describe('Action button component', () => { const description = 'Electron Fiddle Gist'; const errorMessage = '💀'; let app: App; - let mocktokit: OctokitMock; let state: AppState; - let expectedGistOpts: GistCreateOpts; + let expectedGistOpts: GistCreateParams; function getGistFiles(values: EditorValues): GistFiles { return Object.fromEntries( @@ -73,19 +37,24 @@ describe('Action button component', () => { ({ app } = window); ({ state } = app); - // reset static counters between runs - OctokitMock.resetCounters(); + // default IPC mock for gist create so publishGist() can run + vi.mocked(window.ElectronFiddle.gistCreate).mockResolvedValue({ + id: 'created-gist-id', + url: 'https://gist.github.com/created-gist-id', + revision: 'created-sha', + }); - // have the octokit getter use our mock - mocktokit = new OctokitMock(); - vi.mocked(getOctokit).mockImplementation( - async () => mocktokit as unknown as Octokit, - ); + // default IPC mock for gist updates so handleUpdate() can run safely + vi.mocked(window.ElectronFiddle.gistUpdate).mockResolvedValue({ + id: 'gist-id', + url: 'https://gist.github.com/gist-id', + revision: 'updated-sha', + }); // build ExpectedGistCreateOpts const editorValues = createEditorValues(); const files = getGistFiles(editorValues); - expectedGistOpts = { description, files, public: true } as const; + expectedGistOpts = { description, files, isPublic: true }; vi.mocked(window.ElectronFiddle.getTemplate).mockResolvedValue({ [MAIN_JS]: '// content', @@ -175,7 +144,9 @@ describe('Action button component', () => { it('publishes a gist', async () => { state.showInputDialog = vi.fn().mockResolvedValueOnce(description); await instance.performGistAction(); - expect(mocktokit.gists.create).toHaveBeenCalledWith(expectedGistOpts); + expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith( + expectedGistOpts, + ); }); it('marks the Fiddle as saved', async () => { @@ -184,7 +155,9 @@ describe('Action button component', () => { ]); state.showInputDialog = vi.fn().mockResolvedValueOnce(description); await instance.performGistAction(); - expect(mocktokit.gists.create).toHaveBeenCalledWith(expectedGistOpts); + expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith( + expectedGistOpts, + ); expect(state.editorMosaic.isEdited).toBe(false); }); @@ -192,7 +165,7 @@ describe('Action button component', () => { const description = 'some non-default description'; state.showInputDialog = vi.fn().mockResolvedValueOnce(description); await instance.performGistAction(); - expect(mocktokit.gists.create).toHaveBeenCalledWith({ + expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith({ ...expectedGistOpts, description, }); @@ -201,7 +174,7 @@ describe('Action button component', () => { it('publishes only if the user confirms', async () => { state.showInputDialog = vi.fn().mockResolvedValueOnce(undefined); await instance.performGistAction(); - expect(mocktokit.gists.create).not.toHaveBeenCalled(); + expect(window.ElectronFiddle.gistCreate).not.toHaveBeenCalled(); }); describe('empty files', () => { @@ -214,7 +187,7 @@ describe('Action button component', () => { const files = getGistFiles(values); const expected = { ...expectedGistOpts, files }; - expect(mocktokit.gists.create).toHaveBeenCalledWith(expected); + expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith(expected); }); it('are omitted if they are not required files', async () => { @@ -230,7 +203,7 @@ describe('Action button component', () => { const files = getGistFiles(required); const expected = { ...expectedGistOpts, files }; - expect(mocktokit.gists.create).toHaveBeenCalledWith(expected); + expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith(expected); }); it('calls update() if isPublishingGistAsRevision is true', async () => { @@ -245,9 +218,9 @@ describe('Action button component', () => { }); it('handles an error in Gist publishing', async () => { - mocktokit.gists.create.mockImplementation(() => { - throw new Error(errorMessage); - }); + vi.mocked(window.ElectronFiddle.gistCreate).mockRejectedValueOnce( + new Error(errorMessage), + ); state.showInputDialog = vi.fn().mockResolvedValueOnce(description); @@ -261,10 +234,9 @@ describe('Action button component', () => { state.showInputDialog = vi.fn().mockResolvedValueOnce(description); instance.setSecret(); await instance.performGistAction(); - const { create } = mocktokit.gists; - expect(create).toHaveBeenCalledWith({ + expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith({ ...expectedGistOpts, - public: false, + isPublic: false, }); }); @@ -272,21 +244,19 @@ describe('Action button component', () => { state.showInputDialog = vi.fn().mockResolvedValueOnce(description); instance.setPublic(); await instance.performGistAction(); - const { create } = mocktokit.gists; - expect(create).toHaveBeenCalledWith({ + expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith({ ...expectedGistOpts, - public: true, + isPublic: true, }); }); it('sets activeGistRevision to the new revision SHA after publishing', async () => { const revisionSha = 'new-publish-revision-sha'; - mocktokit.gists.create.mockImplementationOnce(() => ({ - data: { - id: 'new-gist-id', - history: [{ version: revisionSha }], - }, - })); + vi.mocked(window.ElectronFiddle.gistCreate).mockResolvedValueOnce({ + id: 'new-gist-id', + url: 'https://gist.github.com/new-gist-id', + revision: revisionSha, + }); state.showInputDialog = vi.fn().mockResolvedValueOnce(description); await instance.performGistAction(); @@ -305,29 +275,29 @@ describe('Action button component', () => { ({ instance } = createActionButton()); act(() => instance.setState({ actionType: GistActionType.update })); - mocktokit.gists.get.mockImplementation(() => { - return { - data: expectedGistOpts, - }; + vi.mocked(window.ElectronFiddle.gistUpdate).mockResolvedValue({ + id: gistId, + url: `https://gist.github.com/${gistId}`, + revision: 'updated-sha', }); }); - it('attempts to update an existing Gist', async () => { + it('attempts to update an existing Gist via IPC', async () => { await instance.performGistAction(); - expect(mocktokit.gists.update).toHaveBeenCalledWith({ - gist_id: gistId, + expect(window.ElectronFiddle.gistUpdate).toHaveBeenCalledWith({ + id: gistId, files: expectedGistOpts.files, }); }); it('sets activeGistRevision to the new revision SHA after updating', async () => { const revisionSha = 'new-update-revision-sha'; - mocktokit.gists.update.mockImplementationOnce(() => ({ - data: { - history: [{ version: revisionSha }], - }, - })); + vi.mocked(window.ElectronFiddle.gistUpdate).mockResolvedValueOnce({ + id: gistId, + url: `https://gist.github.com/${gistId}`, + revision: revisionSha, + }); await instance.performGistAction(); @@ -335,9 +305,9 @@ describe('Action button component', () => { }); it('notifies the user if updating fails', async () => { - mocktokit.gists.update.mockImplementation(() => { - throw new Error(errorMessage); - }); + vi.mocked(window.ElectronFiddle.gistUpdate).mockRejectedValueOnce( + new Error(errorMessage), + ); await instance.performGistAction(); @@ -362,15 +332,15 @@ describe('Action button component', () => { act(() => instance.setState({ actionType: GistActionType.delete })); }); - it('attempts to delete an existing Gist', async () => { + it('attempts to delete an existing Gist via IPC', async () => { await instance.performGistAction(); - expect(mocktokit.gists.delete).toHaveBeenCalledWith({ gist_id: gistId }); + expect(window.ElectronFiddle.gistDelete).toHaveBeenCalledWith(gistId); }); it('notifies the user if deleting fails', async () => { - mocktokit.gists.delete.mockImplementation(() => { - throw new Error(errorMessage); - }); + vi.mocked(window.ElectronFiddle.gistDelete).mockRejectedValueOnce( + new Error(errorMessage), + ); await instance.performGistAction(); diff --git a/tests/renderer/components/dialog-token.spec.tsx b/tests/renderer/components/dialog-token.spec.tsx index d63e4a6d66..6d797cc101 100644 --- a/tests/renderer/components/dialog-token.spec.tsx +++ b/tests/renderer/components/dialog-token.spec.tsx @@ -1,17 +1,13 @@ import * as React from 'react'; -import { Octokit } from '@octokit/rest'; import { act, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TokenDialog } from '../../../src/renderer/components/dialog-token'; import { AppState } from '../../../src/renderer/state'; -import { getOctokit } from '../../../src/renderer/utils/octokit'; import { overrideRendererPlatform } from '../../utils'; import { renderClassComponentWithInstanceRef } from '../utils/renderClassComponentWithInstanceRef'; -vi.mock('../../../src/renderer/utils/octokit'); - describe('TokenDialog component', () => { const mockValidToken = 'ghp_muuHkYenGrOHrTBQKDALW8WtSD929EXMz63n'; const mockInvalidToken = 'testtoken'; @@ -154,27 +150,13 @@ describe('TokenDialog component', () => { }); describe('onSubmitToken()', () => { - let mockOctokit: Octokit; - const mockUser = { - avatar_url: 'https://avatars.fake/hi', - login: 'test-login', - name: 'Test User', - } as const; + const mockLogin = 'test-login'; beforeEach(() => { - mockOctokit = { - authenticate: vi.fn(), - users: { - getAuthenticated: vi.fn().mockResolvedValue({ - data: mockUser, - headers: { - 'x-oauth-scopes': 'gist, repo', - }, - }), - }, - } as unknown as Octokit; - - vi.mocked(getOctokit).mockResolvedValue(mockOctokit); + vi.mocked(window.ElectronFiddle.gitHubSignIn).mockResolvedValue({ + success: true, + login: mockLogin, + }); }); it('handles missing input', async () => { @@ -189,6 +171,7 @@ describe('TokenDialog component', () => { await instance.onSubmitToken(); expect(instance.state.verifying).toBe(false); + expect(window.ElectronFiddle.gitHubSignIn).not.toHaveBeenCalled(); }); it('tries to sign the user in', async () => { @@ -202,16 +185,20 @@ describe('TokenDialog component', () => { await instance.onSubmitToken(); + expect(window.ElectronFiddle.gitHubSignIn).toHaveBeenCalledWith( + mockValidToken, + ); expect(store.gitHubToken).toBe(mockValidToken); - expect(store.gitHubLogin).toBe(mockUser.login); + expect(store.gitHubLogin).toBe(mockLogin); expect(instance.state.error).toBe(false); expect(store.isTokenDialogShowing).toBe(false); }); it('handles an invalid token error', async () => { - vi.mocked(mockOctokit.users.getAuthenticated).mockRejectedValue( - new Error('Bad credentials'), - ); + vi.mocked(window.ElectronFiddle.gitHubSignIn).mockResolvedValueOnce({ + success: false, + error: 'Invalid GitHub token. Please check your token and try again.', + }); store.isTokenDialogShowing = true; const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { @@ -232,41 +219,13 @@ describe('TokenDialog component', () => { expect(store.gitHubToken).toEqual(null); }); - it('handles missing gist scope', async () => { - vi.mocked(mockOctokit.users.getAuthenticated).mockResolvedValue({ - data: mockUser, - headers: { - 'x-oauth-scopes': 'repo, user', // Missing 'gist' scope - }, - } as any); - - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - act(() => { - instance.setState({ tokenInput: mockValidToken }); + it('surfaces the missing-gist-scope error from the main process', async () => { + vi.mocked(window.ElectronFiddle.gitHubSignIn).mockResolvedValueOnce({ + success: false, + error: + 'Token is missing the "gist" scope. Please generate a new token with gist permissions.', }); - await act(async () => { - await instance.onSubmitToken(); - }); - - expect(instance.state.error).toBe(true); - expect(instance.state.errorMessage).toBe( - 'Token is missing the "gist" scope. Please generate a new token with gist permissions.', - ); - expect(store.gitHubToken).toEqual(null); - }); - - it('handles empty scopes header', async () => { - vi.mocked(mockOctokit.users.getAuthenticated).mockResolvedValue({ - data: mockUser, - headers: { - // No x-oauth-scopes header - }, - } as any); - store.isTokenDialogShowing = true; const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { appState: store, @@ -286,120 +245,4 @@ describe('TokenDialog component', () => { expect(store.gitHubToken).toEqual(null); }); }); - - describe('validateGitHubToken()', () => { - let mockOctokit: Octokit; - const mockUser = { - avatar_url: 'https://avatars.fake/hi', - login: 'test-login', - name: 'Test User', - } as const; - - beforeEach(() => { - mockOctokit = { - users: { - getAuthenticated: vi.fn(), - }, - } as unknown as Octokit; - - vi.mocked(getOctokit).mockResolvedValue(mockOctokit); - }); - - it('validates a token with gist scope', async () => { - vi.mocked(mockOctokit.users.getAuthenticated).mockResolvedValue({ - data: mockUser, - headers: { - 'x-oauth-scopes': 'gist, repo, user', - }, - } as any); - - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - - // validateGitHubToken is private — cast needed to test it directly - const result = await (instance as any).validateGitHubToken('valid-token'); - - expect(result).toEqual({ - isValid: true, - scopes: ['gist', 'repo', 'user'], - hasGistScope: true, - user: mockUser, - }); - }); - - it('validates a token without gist scope', async () => { - vi.mocked(mockOctokit.users.getAuthenticated).mockResolvedValue({ - data: mockUser, - headers: { - 'x-oauth-scopes': 'repo, user', - }, - } as any); - - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - - // validateGitHubToken is private - const result = await (instance as any).validateGitHubToken( - 'token-without-gist', - ); - - expect(result).toEqual({ - isValid: true, - scopes: ['repo', 'user'], - hasGistScope: false, - user: mockUser, - }); - }); - - it('handles invalid token', async () => { - vi.mocked(mockOctokit.users.getAuthenticated).mockRejectedValue( - new Error('Bad credentials'), - ); - - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - - // validateGitHubToken is private - const result = await (instance as any).validateGitHubToken( - 'invalid-token', - ); - - expect(result).toEqual({ - isValid: false, - scopes: [], - hasGistScope: false, - error: 'Bad credentials', - }); - }); - - it('handles missing scopes header', async () => { - vi.mocked(mockOctokit.users.getAuthenticated).mockResolvedValue({ - data: mockUser, - headers: {}, - } as any); - - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - - // validateGitHubToken is private - const result = await (instance as any).validateGitHubToken( - 'token-no-scopes-header', - ); - - expect(result).toEqual({ - isValid: true, - scopes: [], - hasGistScope: false, - user: mockUser, - }); - }); - }); }); diff --git a/tests/renderer/components/history.spec.tsx b/tests/renderer/components/history.spec.tsx index 737a991b7c..9e3c9b3549 100644 --- a/tests/renderer/components/history.spec.tsx +++ b/tests/renderer/components/history.spec.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; -import { Octokit } from '@octokit/rest'; import { render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -8,9 +7,6 @@ import { GistRevision } from '../../../src/interfaces'; import { App } from '../../../src/renderer/app'; import { GistHistoryDialog } from '../../../src/renderer/components/history'; import { AppState } from '../../../src/renderer/state'; -import { getOctokit } from '../../../src/renderer/utils/octokit'; - -vi.mock('../../../src/renderer/utils/octokit'); describe('GistHistoryDialog component', () => { let app: App; @@ -45,8 +41,6 @@ describe('GistHistoryDialog component', () => { state.gistId = 'test-gist-id'; state.activeGistRevision = 'sha2'; - - vi.mocked(getOctokit).mockResolvedValue({} as unknown as Octokit); }); function renderDialog( diff --git a/tests/renderer/remote-loader.spec.ts b/tests/renderer/remote-loader.spec.ts index 274b175378..62cb2df8c6 100644 --- a/tests/renderer/remote-loader.spec.ts +++ b/tests/renderer/remote-loader.spec.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { EditorValues, ElectronReleaseChannel, + GistFile, GistRevision, InstallState, MAIN_JS, @@ -18,7 +19,6 @@ import { } from '../../src/renderer/utils/editor-utils'; import { AppMock, StateMock, createEditorValues } from '../mocks/mocks'; -type GistFile = { filename: string; content: string }; type GistFiles = { [id: string]: GistFile }; describe('RemoteLoader', () => { @@ -47,13 +47,10 @@ describe('RemoteLoader', () => { ]), ); - vi.mocked(window.ElectronFiddle.gistLoad).mockImplementation( - async ({ gistId }) => ({ - files: mockGistFiles, - id: gistId, - revision: 'sha1', - }), - ); + vi.mocked(window.ElectronFiddle.gistLoad).mockImplementation(async () => ({ + files: mockGistFiles, + revision: 'sha1', + })); }); describe('fetchGistAndLoad()', () => { @@ -65,7 +62,7 @@ describe('RemoteLoader', () => { expect(result).toBe(true); expect(window.ElectronFiddle.gistLoad).toHaveBeenCalledWith({ - gistId, + id: gistId, revision: undefined, }); expect(app.replaceFiddle).toBeCalledWith(editorValues, { gistId }); @@ -116,7 +113,6 @@ describe('RemoteLoader', () => { 'blah.blah': { filename: 'blah.blah', content: '' }, 'yes.no': { filename: 'yes.no', content: '' }, }, - id: gistId, revision: 'sha1', }); store.gistId = gistId; @@ -259,7 +255,7 @@ describe('RemoteLoader', () => { expect(result).toBe(true); expect(window.ElectronFiddle.gistLoad).toHaveBeenCalledWith({ - gistId, + id: gistId, revision, }); expect(store.activeGistRevision).toBe(revision); From 2d4ea54edcd4b11968ffaffd55b855b0798d392a Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 14:28:03 -0500 Subject: [PATCH 07/18] chore: code cleanup deduplication, dead code, declaration order --- src/constants.ts | 6 ++++++ src/main/github.ts | 18 ++++++------------ .../components/commands-action-button.tsx | 4 ---- src/renderer/components/dialog-token.tsx | 5 ++--- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 343829722d..b77e6b9c53 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,3 +7,9 @@ export const ELECTRON_DTS = 'electron.d.ts'; // We use these to fail fast locally when creating/updating a new gist. export const GIST_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB per file export const GIST_MAX_FILE_COUNT = 300; + +// Matches GitHub personal access tokens (classic `ghp_` and fine-grained +// `github_pat_`). Used in both the renderer (clipboard sniff) and the main +// process (sign-in validation), so they stay in lockstep. +export const GITHUB_TOKEN_PATTERN = + /^(ghp_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})$/; diff --git a/src/main/github.ts b/src/main/github.ts index a561072488..f0a2f9d533 100644 --- a/src/main/github.ts +++ b/src/main/github.ts @@ -6,7 +6,7 @@ import { IpcMainInvokeEvent, app, safeStorage } from 'electron'; import { getTemplate } from './content'; import { ipcMainManager } from './ipc'; -import { GIST_MAX_FILE_COUNT, GIST_MAX_FILE_SIZE } from '../constants'; +import { GIST_MAX_FILE_COUNT, GIST_MAX_FILE_SIZE, GITHUB_TOKEN_PATTERN } from '../constants'; import { EditorValues, GistFile, GistLoadResult, GistRevision, GistWriteResult, GitHubSignInResult } from '../interfaces'; import { IpcEvents } from '../ipc-events'; import { isSupportedFile } from '../utils/editor-utils'; @@ -17,9 +17,6 @@ const ELECTRON_ORG = 'electron'; const ELECTRON_REPO = 'electron'; -const TOKEN_PATTERN = - /^(ghp_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})$/; - const GIST_ID_PATTERN = /^[0-9a-fA-F]{32}$/; const SHA_PATTERN = /^[0-9a-f]{40}$/; @@ -27,7 +24,7 @@ const SHA_PATTERN = /^[0-9a-f]{40}$/; const MAX_DESCRIPTION_LENGTH = 256; function isValidToken(token: unknown): token is string { - return typeof token === 'string' && TOKEN_PATTERN.test(token); + return typeof token === 'string' && GITHUB_TOKEN_PATTERN.test(token); } function isValidGistId(gistId: unknown): gistId is string { @@ -157,12 +154,9 @@ async function handleTokenSignIn( } } -async function handleTokenSignOut( - _event: IpcMainInvokeEvent, -): Promise<{ success: boolean }> { +async function handleTokenSignOut(_event: IpcMainInvokeEvent): Promise { deleteToken(); octokit_ = null; - return { success: true }; } interface CheckAuthResult { @@ -283,7 +277,7 @@ async function handleGistLoad( : await octo.gists.get({ gist_id: id }); const files: GistLoadResult['files'] = {}; - for (const [id, data] of Object.entries(gist.data.files ?? {})) { + for (const [fileId, data] of Object.entries(gist.data.files ?? {})) { if (!data) continue; // When GitHub truncates a large file, data.content is incomplete. @@ -296,8 +290,8 @@ async function handleGistLoad( } } - files[id] = { - filename: data.filename ?? id, + files[fileId] = { + filename: data.filename ?? fileId, content, }; } diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx index 111b158275..fc45c34d0a 100644 --- a/src/renderer/components/commands-action-button.tsx +++ b/src/renderer/components/commands-action-button.tsx @@ -28,8 +28,6 @@ interface GistActionButtonProps { } interface IGistActionButtonState { - readonly isUpdating: boolean; - readonly isDeleting: boolean; readonly actionType: GistActionType; } @@ -49,8 +47,6 @@ export const GistActionButton = observer( this.setPublic = this.setPublic.bind(this); this.state = { - isUpdating: false, - isDeleting: false, actionType: GistActionType.publish, }; diff --git a/src/renderer/components/dialog-token.tsx b/src/renderer/components/dialog-token.tsx index f7df63c82d..f569601a8e 100644 --- a/src/renderer/components/dialog-token.tsx +++ b/src/renderer/components/dialog-token.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { Button, Callout, Dialog, InputGroup, Intent } from '@blueprintjs/core'; import { observer } from 'mobx-react'; +import { GITHUB_TOKEN_PATTERN } from '../../constants'; import { AppState } from '../state'; interface TokenDialogProps { @@ -19,8 +20,6 @@ interface TokenDialogState { const TOKEN_SCOPES = ['gist'].join(); const TOKEN_DESCRIPTION = encodeURIComponent('Fiddle Gist Token'); const GENERATE_TOKEN_URL = `https://github.com/settings/tokens/new?scopes=${TOKEN_SCOPES}&description=${TOKEN_DESCRIPTION}`; -const TOKEN_PATTERN = - /^(ghp_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})$/; /** * The token dialog asks the user for a GitHub Personal Access Token. @@ -115,7 +114,7 @@ export const TokenDialog = observer( public async onTokenInputFocused() { const text = ((await navigator.clipboard.readText()) || '').trim(); - if (TOKEN_PATTERN.test(text)) { + if (GITHUB_TOKEN_PATTERN.test(text)) { this.setState({ tokenInput: text }); } } From 8f1484da176f20f257513f067a2d88346faa9975 Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 14:40:20 -0500 Subject: [PATCH 08/18] refactor: finish migrating GitHub tokens to main --- src/ambient.d.ts | 3 +++ src/interfaces.ts | 4 ++++ src/main/github.ts | 8 ++------ src/preload/preload.ts | 3 +++ src/renderer/app.tsx | 6 ++++++ .../components/commands-action-button.tsx | 4 ++-- src/renderer/components/dialog-token.tsx | 7 ++++--- .../components/settings-general-github.tsx | 4 ++-- src/renderer/state.ts | 20 +++++++++++-------- tests/mocks/electron-fiddle.ts | 2 ++ tests/mocks/state.ts | 2 -- tests/renderer/app.spec.tsx | 3 +++ .../commands-publish-button.spec.tsx | 4 ++-- .../renderer/components/dialog-token.spec.tsx | 5 ++--- .../settings-general-github.spec.tsx | 1 - tests/renderer/state.spec.ts | 8 ++++---- 16 files changed, 51 insertions(+), 33 deletions(-) diff --git a/src/ambient.d.ts b/src/ambient.d.ts index f49ab8039e..fd090efbf3 100644 --- a/src/ambient.d.ts +++ b/src/ambient.d.ts @@ -13,6 +13,7 @@ import { GistRevision, GistUpdateParams, GistWriteResult, + GitHubCheckAuthResult, GitHubSignInResult, IPackageManager, InstallState, @@ -119,7 +120,9 @@ declare global { gistListCommits(gistId: string): Promise; gistLoad(params: GistLoadParams): Promise; gistUpdate(params: GistUpdateParams): Promise; + gitHubCheckAuth(): Promise; gitHubSignIn(token: string): Promise; + gitHubSignOut(): Promise; getAvailableThemes(): Promise>; getElectronTypes(ver: RunnableVersion): Promise; getIsPackageManagerInstalled( diff --git a/src/interfaces.ts b/src/interfaces.ts index c86e9c9e2d..606c4451a3 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -257,6 +257,10 @@ export interface GitHubSignInResult { error?: string; } +export interface GitHubCheckAuthResult { + login: string | null; +} + export enum GlobalSetting { acceleratorsToBlock = 'acceleratorsToBlock', channelsToShow = 'channelsToShow', diff --git a/src/main/github.ts b/src/main/github.ts index f0a2f9d533..8f9518989f 100644 --- a/src/main/github.ts +++ b/src/main/github.ts @@ -7,7 +7,7 @@ import { IpcMainInvokeEvent, app, safeStorage } from 'electron'; import { getTemplate } from './content'; import { ipcMainManager } from './ipc'; import { GIST_MAX_FILE_COUNT, GIST_MAX_FILE_SIZE, GITHUB_TOKEN_PATTERN } from '../constants'; -import { EditorValues, GistFile, GistLoadResult, GistRevision, GistWriteResult, GitHubSignInResult } from '../interfaces'; +import { EditorValues, GistFile, GistLoadResult, GistRevision, GistWriteResult, GitHubCheckAuthResult, GitHubSignInResult } from '../interfaces'; import { IpcEvents } from '../ipc-events'; import { isSupportedFile } from '../utils/editor-utils'; @@ -159,13 +159,9 @@ async function handleTokenSignOut(_event: IpcMainInvokeEvent): Promise { octokit_ = null; } -interface CheckAuthResult { - login: string | null; -} - async function handleTokenCheckAuth( _event: IpcMainInvokeEvent, -): Promise { +): Promise { const token = loadToken(); if (!token) return { login: null }; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index e247e15a66..345198439c 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -121,8 +121,11 @@ export async function setupFiddleGlobal() { ipcRenderer.invoke(IpcEvents.GITHUB_GIST_LOAD, params), gistUpdate: (params: GistUpdateParams) => ipcRenderer.invoke(IpcEvents.GITHUB_GIST_UPDATE, params), + gitHubCheckAuth: () => + ipcRenderer.invoke(IpcEvents.GITHUB_TOKEN_CHECK_AUTH), gitHubSignIn: (token: string) => ipcRenderer.invoke(IpcEvents.GITHUB_TOKEN_SIGN_IN, token), + gitHubSignOut: () => ipcRenderer.invoke(IpcEvents.GITHUB_TOKEN_SIGN_OUT), getElectronTypes(ver: RunnableVersion) { return ipcRenderer.invoke(IpcEvents.GET_ELECTRON_TYPES, ver); }, diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 7c237ef8ab..906bd14d06 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -161,6 +161,12 @@ export class App { this.setupUnloadListeners(); this.setupTypeListeners(); + // Restore signed-in state from main's encrypted credential, if any. + // Fire-and-forget; the UI works fine while this resolves. + void window.ElectronFiddle.gitHubCheckAuth().then(({ login }) => { + this.state.gitHubLogin = login; + }); + window.ElectronFiddle.sendReady(); window.ElectronFiddle.addEventListener('set-show-me-template', () => { diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx index fc45c34d0a..9a96297ad4 100644 --- a/src/renderer/components/commands-action-button.tsx +++ b/src/renderer/components/commands-action-button.tsx @@ -80,14 +80,14 @@ export const GistActionButton = observer( public async handleClick(): Promise { const { appState } = this.props; - if (!appState.gitHubToken) { + if (!appState.gitHubLogin) { appState.toggleAuthDialog(); } // Wait for the dialog to be closed again await when(() => !appState.isTokenDialogShowing); - if (appState.gitHubToken) { + if (appState.gitHubLogin) { return this.performGistAction(); } } diff --git a/src/renderer/components/dialog-token.tsx b/src/renderer/components/dialog-token.tsx index f569601a8e..652a74724e 100644 --- a/src/renderer/components/dialog-token.tsx +++ b/src/renderer/components/dialog-token.tsx @@ -68,12 +68,13 @@ export const TokenDialog = observer( error: true, errorMessage: result.error, }); - this.props.appState.gitHubToken = null; + this.props.appState.gitHubLogin = null; return; } - // Token is valid and has required scopes. - this.props.appState.gitHubToken = this.state.tokenInput; + // Token is valid and has required scopes. The token itself stays in + // the main process; the renderer only tracks the login as a "signed + // in?" signal. this.props.appState.gitHubLogin = result.login ?? null; this.setState({ verifying: false, error: false }); diff --git a/src/renderer/components/settings-general-github.tsx b/src/renderer/components/settings-general-github.tsx index ddd6949fb6..0e52afde83 100644 --- a/src/renderer/components/settings-general-github.tsx +++ b/src/renderer/components/settings-general-github.tsx @@ -72,10 +72,10 @@ export const GitHubSettings = observer( } public render() { - const { gitHubToken } = this.props.appState; + const { gitHubLogin } = this.props.appState; const { isPublishingGistAsRevision } = this.props.appState; - const maybeSignedIn = !!gitHubToken + const maybeSignedIn = !!gitHubLogin ? this.renderSignedIn() : this.renderNotSignedIn(); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 925ad22a83..6a73d5d51c 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -42,6 +42,12 @@ import { } from '../interfaces'; import { Bisector } from '../utils/bisect'; +// Migration: previous versions of Fiddle stored the GitHub PAT in +// localStorage in plaintext. The token now lives encrypted in the main +// process; remove any leftover renderer copy on startup so it doesn't +// linger forever. +localStorage.removeItem(GlobalSetting.gitHubToken); + /** * The application's state. Exported as a singleton below. */ @@ -59,8 +65,6 @@ export class AppState { public gitHubLogin: string | null = localStorage.getItem( GlobalSetting.gitHubLogin, ); - public gitHubToken: string | null = - localStorage.getItem(GlobalSetting.gitHubToken) || null; public gitHubPublishAsPublic = !!this.retrieve( WindowSpecificSetting.gitHubPublishAsPublic, ); @@ -221,7 +225,6 @@ export class AppState { activeGistRevision: observable, gitHubLogin: observable, gitHubPublishAsPublic: observable, - gitHubToken: observable, hideChannels: action, isAddVersionDialogShowing: observable, isAutoBisecting: observable, @@ -393,6 +396,7 @@ export class AppState { // This key is deprecated, so do nothing // These keys are deprecated, so do nothing + case GlobalSetting.gitHubToken: case GlobalSetting.knownVersion: case GlobalSetting.localVersion: { break; @@ -406,7 +410,6 @@ export class AppState { case GlobalSetting.fontFamily: case GlobalSetting.fontSize: case GlobalSetting.gitHubLogin: - case GlobalSetting.gitHubToken: case GlobalSetting.isClearingConsoleOnRun: case GlobalSetting.isEnablingElectronLogging: case GlobalSetting.isKeepingUserDataDirs: @@ -495,7 +498,6 @@ export class AppState { ), ); autorun(() => this.save(GlobalSetting.gitHubLogin, this.gitHubLogin)); - autorun(() => this.save(GlobalSetting.gitHubToken, this.gitHubToken)); autorun(() => this.save( WindowSpecificSetting.gitHubPublishAsPublic, @@ -981,11 +983,13 @@ export class AppState { } /** - * The equivalent of signing out. + * The equivalent of signing out. Tells main to delete its encrypted + * credential and clear the cached Octokit, then clears the renderer's + * "signed in" indicator. */ - public signOutGitHub(): void { + public async signOutGitHub(): Promise { + await window.ElectronFiddle.gitHubSignOut(); this.gitHubLogin = null; - this.gitHubToken = null; } public async showGenericDialog( diff --git a/tests/mocks/electron-fiddle.ts b/tests/mocks/electron-fiddle.ts index d84a14c4d4..0d3bb43348 100644 --- a/tests/mocks/electron-fiddle.ts +++ b/tests/mocks/electron-fiddle.ts @@ -16,7 +16,9 @@ export class ElectronFiddleMock { public gistListCommits = vi.fn(); public gistLoad = vi.fn(); public gistUpdate = vi.fn(); + public gitHubCheckAuth = vi.fn(); public gitHubSignIn = vi.fn(); + public gitHubSignOut = vi.fn(); public getAvailableThemes = vi.fn(); public getElectronTypes = vi.fn(); public getIsPackageManagerInstalled = vi.fn(); diff --git a/tests/mocks/state.ts b/tests/mocks/state.ts index bebbfe7bc2..20859223d7 100644 --- a/tests/mocks/state.ts +++ b/tests/mocks/state.ts @@ -28,7 +28,6 @@ export class StateMock { public activeGistRevision: string | undefined = undefined; public gitHubLogin: string | null = null; public gitHubPublishAsPublic = true; - public gitHubToken: string | null = null; public isAddVersionDialogShowing = false; public isAutoBisecting = false; public isClearingConsoleOnRun = false; @@ -127,7 +126,6 @@ export class StateMock { gistId: observable, gitHubLogin: observable, gitHubPublishAsPublic: observable, - gitHubToken: observable, isAddVersionDialogShowing: observable, isAutoBisecting: observable, isClearingConsoleOnRun: observable, diff --git a/tests/renderer/app.spec.tsx b/tests/renderer/app.spec.tsx index 83c94baf8c..213eaee7c8 100644 --- a/tests/renderer/app.spec.tsx +++ b/tests/renderer/app.spec.tsx @@ -38,6 +38,9 @@ describe('App component', () => { vi.mocked(window.ElectronFiddle.getLatestStable).mockReturnValue( semver.parse('24.0.0')!, ); + vi.mocked(window.ElectronFiddle.gitHubCheckAuth).mockResolvedValue({ + login: null, + }); const { app: appMock } = window; const { electronTypes, fileManager, remoteLoader, runner, state } = appMock; diff --git a/tests/renderer/components/commands-publish-button.spec.tsx b/tests/renderer/components/commands-publish-button.spec.tsx index ac8238b1b2..6f061d1042 100644 --- a/tests/renderer/components/commands-publish-button.spec.tsx +++ b/tests/renderer/components/commands-publish-button.spec.tsx @@ -115,7 +115,7 @@ describe('Action button component', () => { // If authed, continue to performGistAction vi.mocked(state.toggleAuthDialog).mockImplementationOnce( - () => (state.gitHubToken = 'github-token'), + () => (state.gitHubLogin = 'test-user'), ); await instance.handleClick(); expect(state.toggleAuthDialog).toHaveBeenCalled(); @@ -123,7 +123,7 @@ describe('Action button component', () => { }); it('toggles the publish method on click if authed', async () => { - state.gitHubToken = 'github-token'; + state.gitHubLogin = 'test-user'; const { instance } = createActionButton(); instance.performGistAction = vi.fn(); diff --git a/tests/renderer/components/dialog-token.spec.tsx b/tests/renderer/components/dialog-token.spec.tsx index 6d797cc101..8c53d8921a 100644 --- a/tests/renderer/components/dialog-token.spec.tsx +++ b/tests/renderer/components/dialog-token.spec.tsx @@ -188,7 +188,6 @@ describe('TokenDialog component', () => { expect(window.ElectronFiddle.gitHubSignIn).toHaveBeenCalledWith( mockValidToken, ); - expect(store.gitHubToken).toBe(mockValidToken); expect(store.gitHubLogin).toBe(mockLogin); expect(instance.state.error).toBe(false); expect(store.isTokenDialogShowing).toBe(false); @@ -216,7 +215,7 @@ describe('TokenDialog component', () => { expect(instance.state.errorMessage).toBe( 'Invalid GitHub token. Please check your token and try again.', ); - expect(store.gitHubToken).toEqual(null); + expect(store.gitHubLogin).toEqual(null); }); it('surfaces the missing-gist-scope error from the main process', async () => { @@ -242,7 +241,7 @@ describe('TokenDialog component', () => { expect(instance.state.errorMessage).toBe( 'Token is missing the "gist" scope. Please generate a new token with gist permissions.', ); - expect(store.gitHubToken).toEqual(null); + expect(store.gitHubLogin).toEqual(null); }); }); }); diff --git a/tests/renderer/components/settings-general-github.spec.tsx b/tests/renderer/components/settings-general-github.spec.tsx index d2fd2cd1c8..149ebf3de0 100644 --- a/tests/renderer/components/settings-general-github.spec.tsx +++ b/tests/renderer/components/settings-general-github.spec.tsx @@ -21,7 +21,6 @@ describe('GitHubSettings component', () => { }); it('renders when signed in', () => { - store.gitHubToken = '123'; store.gitHubLogin = 'Test User'; render(); diff --git a/tests/renderer/state.spec.ts b/tests/renderer/state.spec.ts index c129b991af..9dcce74327 100644 --- a/tests/renderer/state.spec.ts +++ b/tests/renderer/state.spec.ts @@ -736,13 +736,13 @@ describe('AppState', () => { }); describe('signOutGitHub()', () => { - it('resets all GitHub information', () => { + it('clears the login indicator and tells main to drop the token', async () => { appState.gitHubLogin = 'test'; - appState.gitHubToken = 'test'; - appState.signOutGitHub(); + await appState.signOutGitHub(); + + expect(window.ElectronFiddle.gitHubSignOut).toHaveBeenCalled(); expect(appState.gitHubLogin).toBe(null); - expect(appState.gitHubToken).toBe(null); }); }); From fa7f5e16f58896cdb2b14af13827cf3dc840c3f4 Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 14:59:38 -0500 Subject: [PATCH 09/18] refactor: prefer gistId over id for readability --- src/interfaces.ts | 4 +-- src/main/github.ts | 25 ++++++++------- .../components/commands-action-button.tsx | 3 +- src/renderer/remote-loader.ts | 2 +- tests/main/github.spec.ts | 31 ++++++++++--------- .../commands-publish-button.spec.tsx | 2 +- tests/renderer/remote-loader.spec.ts | 4 +-- 7 files changed, 37 insertions(+), 34 deletions(-) diff --git a/src/interfaces.ts b/src/interfaces.ts index 606c4451a3..35324ab799 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -231,7 +231,7 @@ export interface GistFile { } export interface GistLoadParams { - id: string; + gistId: string; revision?: string; } @@ -241,7 +241,7 @@ export interface GistLoadResult { } export interface GistUpdateParams { - id: string; + gistId: string; files: Record; } diff --git a/src/main/github.ts b/src/main/github.ts index 8f9518989f..628aca8e3e 100644 --- a/src/main/github.ts +++ b/src/main/github.ts @@ -147,6 +147,7 @@ async function handleTokenSignIn( return { success: true, login: response.data.login }; } catch (error: any) { + console.warn('GitHub token sign-in failed', error); return { success: false, error: 'Invalid GitHub token. Please check your token and try again.', @@ -218,23 +219,25 @@ async function handleGistUpdate( if (typeof params !== 'object' || params === null) throw new Error('Invalid parameters.'); - const { id, files } = params as Record; + const { gistId, files } = params as Record; - if (!isValidGistId(id)) throw new Error('Invalid gist ID.'); + if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.'); if (!areValidGistFiles(files)) throw new Error('Invalid files payload.'); const octo = getAuthenticatedOctokit(); // Fetch existing files to detect deletions - const { data: existing } = await octo.gists.get({ gist_id: id }); - const updateFiles = { ...(files as Record) }; + const { data: existing } = await octo.gists.get({ gist_id: gistId }); + const updateFiles: Record = { ...files }; for (const fileId of Object.keys(existing.files ?? {})) { - if (!(fileId in updateFiles)) updateFiles[fileId] = null as any; + if (!(fileId in updateFiles)) updateFiles[fileId] = null; } const gist = await octo.gists.update({ - gist_id: id, - files: updateFiles as any, + gist_id: gistId, + // Octokit's generated types don't model file deletion (null), but the + // REST API requires it. Cast only at the boundary. + files: updateFiles as Record, }); return { @@ -261,16 +264,16 @@ async function handleGistLoad( if (typeof params !== 'object' || params === null) throw new Error('Invalid parameters.'); - const { id, revision } = params as Record; + const { gistId, revision } = params as Record; - if (!isValidGistId(id)) throw new Error('Invalid gist ID.'); + if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.'); if (revision !== undefined && !isValidSha(revision)) throw new Error('Invalid revision SHA.'); const octo = getOctokit(); const gist = revision - ? await octo.gists.getRevision({ gist_id: id, sha: revision }) - : await octo.gists.get({ gist_id: id }); + ? await octo.gists.getRevision({ gist_id: gistId, sha: revision }) + : await octo.gists.get({ gist_id: gistId }); const files: GistLoadResult['files'] = {}; for (const [fileId, data] of Object.entries(gist.data.files ?? {})) { diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx index 9a96297ad4..d1e5f8ca45 100644 --- a/src/renderer/components/commands-action-button.tsx +++ b/src/renderer/components/commands-action-button.tsx @@ -196,7 +196,7 @@ export const GistActionButton = observer( const files = this.gistFilesList(values); const gist = await window.ElectronFiddle.gistUpdate({ - id: appState.gistId!, + gistId: appState.gistId!, files, }); @@ -245,7 +245,6 @@ export const GistActionButton = observer( await window.ElectronFiddle.gistDelete(appState.gistId!); appState.editorMosaic.clearSaved(); - console.log('Deleting: Deleting done'); this.renderToast({ message: 'Successfully deleted gist!' }); } catch (error: any) { console.warn(`Could not delete gist`, { error }); diff --git a/src/renderer/remote-loader.ts b/src/renderer/remote-loader.ts index c7c225e624..d5cbe1dfb6 100644 --- a/src/renderer/remote-loader.ts +++ b/src/renderer/remote-loader.ts @@ -95,7 +95,7 @@ export class RemoteLoader { ): Promise { try { const gist = await window.ElectronFiddle.gistLoad({ - id: gistId, + gistId, revision, }); diff --git a/tests/main/github.spec.ts b/tests/main/github.spec.ts index e757e1838d..d6b35f69e1 100644 --- a/tests/main/github.spec.ts +++ b/tests/main/github.spec.ts @@ -387,7 +387,7 @@ describe('github', () => { it('updates a gist with valid parameters', async () => { const result = await handleGistUpdate(MOCK_EVENT, { - id: VALID_GIST_ID, + gistId: VALID_GIST_ID, files: { 'main.js': { filename: 'main.js', content: 'new code' }, 'old.js': null, @@ -401,7 +401,7 @@ describe('github', () => { for (const gistId of INVALID_GIST_IDS) { await expect( handleGistUpdate(MOCK_EVENT, { - id: gistId, + gistId, files: VALID_FILES, }), ).rejects.toThrow('Invalid gist ID'); @@ -418,7 +418,7 @@ describe('github', () => { for (const files of invalidFiles) { await expect( handleGistUpdate(MOCK_EVENT, { - id: VALID_GIST_ID, + gistId: VALID_GIST_ID, files, }), ).rejects.toThrow('Invalid files'); @@ -426,7 +426,7 @@ describe('github', () => { await expect( handleGistUpdate(MOCK_EVENT, { - id: VALID_GIST_ID, + gistId: VALID_GIST_ID, files: 'not-an-object', }), ).rejects.toThrow('Invalid files'); @@ -476,7 +476,7 @@ describe('github', () => { it('loads a gist by ID', async () => { const result = await handleGistLoad(MOCK_EVENT, { - id: VALID_GIST_ID, + gistId: VALID_GIST_ID, }); expect(result.files['main.js'].content).toBe('console.log("hi")'); @@ -484,7 +484,7 @@ describe('github', () => { it('loads a gist at a specific revision', async () => { const result = await handleGistLoad(MOCK_EVENT, { - id: VALID_GIST_ID, + gistId: VALID_GIST_ID, revision: VALID_SHA, }); @@ -493,16 +493,16 @@ describe('github', () => { it('rejects invalid gist IDs', async () => { for (const gistId of INVALID_GIST_IDS) { - await expect( - handleGistLoad(MOCK_EVENT, { id: gistId }), - ).rejects.toThrow('Invalid gist ID'); + await expect(handleGistLoad(MOCK_EVENT, { gistId })).rejects.toThrow( + 'Invalid gist ID', + ); } }); it('rejects invalid revision SHA', async () => { await expect( handleGistLoad(MOCK_EVENT, { - id: VALID_GIST_ID, + gistId: VALID_GIST_ID, revision: 'not-a-sha', }), ).rejects.toThrow('Invalid revision SHA'); @@ -511,7 +511,7 @@ describe('github', () => { it('loads a gist with valid or omitted revision', async () => { for (const revision of [VALID_SHA, undefined]) { const result = await handleGistLoad(MOCK_EVENT, { - id: VALID_GIST_ID, + gistId: VALID_GIST_ID, revision, }); @@ -523,7 +523,7 @@ describe('github', () => { for (const revision of ['abc123', null, 'a'.repeat(41), 'A'.repeat(40)]) { await expect( handleGistLoad(MOCK_EVENT, { - id: VALID_GIST_ID, + gistId: VALID_GIST_ID, revision, }), ).rejects.toThrow('Invalid revision SHA'); @@ -542,13 +542,14 @@ describe('github', () => { mockOctokitInstance(); const result = await handleGistLoad(MOCK_EVENT, { - id: VALID_GIST_ID, + gistId: VALID_GIST_ID, }); expect(result.files['main.js'].content).toBe('console.log("hi")'); }); it('fetches full content for truncated files', async () => { - const fullContent = 'a'.repeat(2000); + // This is the largest allowable size a gist file can be + const fullContent = 'a'.repeat(GIST_MAX_FILE_SIZE); // Sign out and re-sign-in with a mock that returns a truncated file await handleTokenSignOut(MOCK_EVENT); @@ -579,7 +580,7 @@ describe('github', () => { } as Response); const result = await handleGistLoad(MOCK_EVENT, { - id: VALID_GIST_ID, + gistId: VALID_GIST_ID, }); expect(global.fetch).toHaveBeenCalledWith( diff --git a/tests/renderer/components/commands-publish-button.spec.tsx b/tests/renderer/components/commands-publish-button.spec.tsx index 6f061d1042..542d51bf03 100644 --- a/tests/renderer/components/commands-publish-button.spec.tsx +++ b/tests/renderer/components/commands-publish-button.spec.tsx @@ -286,7 +286,7 @@ describe('Action button component', () => { await instance.performGistAction(); expect(window.ElectronFiddle.gistUpdate).toHaveBeenCalledWith({ - id: gistId, + gistId, files: expectedGistOpts.files, }); }); diff --git a/tests/renderer/remote-loader.spec.ts b/tests/renderer/remote-loader.spec.ts index 62cb2df8c6..a41373eccc 100644 --- a/tests/renderer/remote-loader.spec.ts +++ b/tests/renderer/remote-loader.spec.ts @@ -62,7 +62,7 @@ describe('RemoteLoader', () => { expect(result).toBe(true); expect(window.ElectronFiddle.gistLoad).toHaveBeenCalledWith({ - id: gistId, + gistId, revision: undefined, }); expect(app.replaceFiddle).toBeCalledWith(editorValues, { gistId }); @@ -255,7 +255,7 @@ describe('RemoteLoader', () => { expect(result).toBe(true); expect(window.ElectronFiddle.gistLoad).toHaveBeenCalledWith({ - id: gistId, + gistId, revision, }); expect(store.activeGistRevision).toBe(revision); From ace1f14262f808d5c2b8c8d5bc24a49eb54c0125 Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 15:33:18 -0500 Subject: [PATCH 10/18] fix: ensure auth restore completes before signalling ready Ensure the main process Octokit is initialized before queued IPC messages are flushed. Ensuring an authenticated octokit connection avoids edge cases like loading a private gist on startup. --- src/renderer/app.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 906bd14d06..9fddb66ae2 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -162,13 +162,15 @@ export class App { this.setupTypeListeners(); // Restore signed-in state from main's encrypted credential, if any. - // Fire-and-forget; the UI works fine while this resolves. - void window.ElectronFiddle.gitHubCheckAuth().then(({ login }) => { + // Wait for auth restore before signalling ready so that queued IPC + // messages (e.g. deep-linked private gist loads) use the authenticated + // Octokit instance. + window.ElectronFiddle.gitHubCheckAuth().then(({ login }) => { this.state.gitHubLogin = login; + }).finally(() => { + window.ElectronFiddle.sendReady(); }); - window.ElectronFiddle.sendReady(); - window.ElectronFiddle.addEventListener('set-show-me-template', () => { window.ElectronFiddle.setShowMeTemplate(this.state.templateName); }); From 913ebcfc174ecdbdc5bac54c30ae54ae4f4f06a7 Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 15:36:44 -0500 Subject: [PATCH 11/18] refactor: reduce setup duplication in tests --- .../commands-publish-button.spec.tsx | 68 +++--- .../renderer/components/dialog-token.spec.tsx | 197 +++++++----------- 2 files changed, 119 insertions(+), 146 deletions(-) diff --git a/tests/renderer/components/commands-publish-button.spec.tsx b/tests/renderer/components/commands-publish-button.spec.tsx index 542d51bf03..0047b77acb 100644 --- a/tests/renderer/components/commands-publish-button.spec.tsx +++ b/tests/renderer/components/commands-publish-button.spec.tsx @@ -67,6 +67,30 @@ describe('Action button component', () => { }); } + type ActionButtonInstance = ReturnType['instance']; + + function createModeActionButton( + actionType: GistActionType, + gistId = '123', + ): ActionButtonInstance { + state.gistId = gistId; + const { instance } = createActionButton(); + act(() => instance.setState({ actionType })); + return instance; + } + + async function performWithDescription( + instance: ActionButtonInstance, + nextDescription: string | undefined, + ) { + state.showInputDialog = vi.fn().mockResolvedValueOnce(nextDescription); + await instance.performGistAction(); + } + + async function performWithDefaultDescription(instance: ActionButtonInstance) { + await performWithDescription(instance, description); + } + it('renders', () => { const { renderResult } = createActionButton(); const button = renderResult.getByTestId('button-action'); @@ -142,8 +166,7 @@ describe('Action button component', () => { }); it('publishes a gist', async () => { - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); - await instance.performGistAction(); + await performWithDefaultDescription(instance); expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith( expectedGistOpts, ); @@ -153,8 +176,7 @@ describe('Action button component', () => { (state.editorMosaic as any).currentHashes = new Map([ [MAIN_JS, 'abc123'], ]); - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); - await instance.performGistAction(); + await performWithDefaultDescription(instance); expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith( expectedGistOpts, ); @@ -162,18 +184,16 @@ describe('Action button component', () => { }); it('asks the user for a description', async () => { - const description = 'some non-default description'; - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); - await instance.performGistAction(); + const nextDescription = 'some non-default description'; + await performWithDescription(instance, nextDescription); expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith({ ...expectedGistOpts, - description, + description: nextDescription, }); }); it('publishes only if the user confirms', async () => { - state.showInputDialog = vi.fn().mockResolvedValueOnce(undefined); - await instance.performGistAction(); + await performWithDescription(instance, undefined); expect(window.ElectronFiddle.gistCreate).not.toHaveBeenCalled(); }); @@ -182,8 +202,7 @@ describe('Action button component', () => { const values = { [MAIN_JS]: '' } as const; vi.mocked(app.getEditorValues).mockResolvedValueOnce(values); - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); - await instance.performGistAction(); + await performWithDefaultDescription(instance); const files = getGistFiles(values); const expected = { ...expectedGistOpts, files }; @@ -198,8 +217,7 @@ describe('Action button component', () => { ...required, ...optional, }); - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); - await instance.performGistAction(); + await performWithDefaultDescription(instance); const files = getGistFiles(required); const expected = { ...expectedGistOpts, files }; @@ -222,18 +240,15 @@ describe('Action button component', () => { new Error(errorMessage), ); - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); - - await instance.performGistAction(); + await performWithDefaultDescription(instance); expect(state.activeGistAction).toBe(GistActionState.none); // On failure the editor should still be considered edited }); it('can publish secret gists', async () => { - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); instance.setSecret(); - await instance.performGistAction(); + await performWithDefaultDescription(instance); expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith({ ...expectedGistOpts, isPublic: false, @@ -241,9 +256,8 @@ describe('Action button component', () => { }); it('can publish public gists', async () => { - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); instance.setPublic(); - await instance.performGistAction(); + await performWithDefaultDescription(instance); expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith({ ...expectedGistOpts, isPublic: true, @@ -258,8 +272,7 @@ describe('Action button component', () => { revision: revisionSha, }); - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); - await instance.performGistAction(); + await performWithDefaultDescription(instance); expect(state.activeGistRevision).toBe(revisionSha); }); @@ -271,9 +284,7 @@ describe('Action button component', () => { beforeEach(() => { // create a button that's primed to update gistId - state.gistId = gistId; - ({ instance } = createActionButton()); - act(() => instance.setState({ actionType: GistActionType.update })); + instance = createModeActionButton(GistActionType.update, gistId); vi.mocked(window.ElectronFiddle.gistUpdate).mockResolvedValue({ id: gistId, @@ -325,11 +336,8 @@ describe('Action button component', () => { let instance: any; beforeEach(() => { - state.gistId = gistId; - // create a button primed to delete gistId - ({ instance } = createActionButton()); - act(() => instance.setState({ actionType: GistActionType.delete })); + instance = createModeActionButton(GistActionType.delete, gistId); }); it('attempts to delete an existing Gist via IPC', async () => { diff --git a/tests/renderer/components/dialog-token.spec.tsx b/tests/renderer/components/dialog-token.spec.tsx index 8c53d8921a..df3810a1e5 100644 --- a/tests/renderer/components/dialog-token.spec.tsx +++ b/tests/renderer/components/dialog-token.spec.tsx @@ -11,8 +11,65 @@ import { renderClassComponentWithInstanceRef } from '../utils/renderClassCompone describe('TokenDialog component', () => { const mockValidToken = 'ghp_muuHkYenGrOHrTBQKDALW8WtSD929EXMz63n'; const mockInvalidToken = 'testtoken'; + const resetState = { + verifying: false, + error: false, + errorMessage: undefined, + tokenInput: '', + }; let store: AppState; + function renderDialog() { + store.isTokenDialogShowing = true; + return render(); + } + + function createDialog() { + store.isTokenDialogShowing = true; + return renderClassComponentWithInstanceRef(TokenDialog, { + appState: store, + }); + } + + type TokenDialogInstance = ReturnType['instance']; + + function setDialogState( + instance: TokenDialogInstance, + nextState: Partial, + ) { + act(() => { + instance.setState(nextState); + }); + } + + function expectResetState(instance: TokenDialogInstance) { + expect(instance.state).toEqual(resetState); + } + + async function submitToken( + instance: TokenDialogInstance, + token = mockValidToken, + ) { + setDialogState(instance, { tokenInput: token }); + await act(async () => { + await instance.onSubmitToken(); + }); + } + + async function expectSubmitError(errorMessage: string) { + vi.mocked(window.ElectronFiddle.gitHubSignIn).mockResolvedValueOnce({ + success: false, + error: errorMessage, + }); + + const { instance } = createDialog(); + await submitToken(instance); + + expect(instance.state.error).toBe(true); + expect(instance.state.errorMessage).toBe(errorMessage); + expect(store.gitHubLogin).toBeNull(); + } + beforeEach(() => { // We render the buttons different depending on the // platform, so let' have a uniform platform for unit tests @@ -22,8 +79,7 @@ describe('TokenDialog component', () => { }); it('renders', () => { - store.isTokenDialogShowing = true; - render(); + renderDialog(); expect(screen.getByText('GitHub Token')).toBeInTheDocument(); expect(screen.getByText('Done')).toBeInTheDocument(); @@ -34,10 +90,7 @@ describe('TokenDialog component', () => { }); it('tries to read the clipboard on focus and enters it if valid', async () => { - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); + const { instance } = createDialog(); vi.mocked(window.navigator.clipboard.readText).mockResolvedValueOnce( mockValidToken, @@ -51,10 +104,7 @@ describe('TokenDialog component', () => { }); it('tries to read the clipboard on focus and does not enter it if invalid', async () => { - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); + const { instance } = createDialog(); vi.mocked(window.navigator.clipboard.readText).mockResolvedValueOnce( mockInvalidToken, @@ -68,63 +118,36 @@ describe('TokenDialog component', () => { }); it('reset() resets the component', () => { - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - - act(() => { - instance.setState({ - verifying: true, - tokenInput: 'hello', - errorMessage: 'test error', - }); + const { instance } = createDialog(); + setDialogState(instance, { + verifying: true, + tokenInput: 'hello', + errorMessage: 'test error', }); act(() => { instance.reset(); }); - expect(instance.state).toEqual({ - verifying: false, - error: false, - errorMessage: undefined, - tokenInput: '', - }); + expectResetState(instance); }); it('onClose() resets the component', () => { - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - - act(() => { - instance.setState({ - verifying: true, - tokenInput: 'hello', - errorMessage: 'test error', - }); + const { instance } = createDialog(); + setDialogState(instance, { + verifying: true, + tokenInput: 'hello', + errorMessage: 'test error', }); act(() => { instance.onClose(); }); - expect(instance.state).toEqual({ - verifying: false, - error: false, - errorMessage: undefined, - tokenInput: '', - }); + expectResetState(instance); }); it('handleChange() handles the change event', () => { - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - act(() => { - instance.setState({ verifying: true, tokenInput: 'hello' }); - }); + const { instance } = createDialog(); + setDialogState(instance, { verifying: true, tokenInput: 'hello' }); act(() => { instance.handleChange({ @@ -136,14 +159,8 @@ describe('TokenDialog component', () => { }); it('openGenerateTokenExternal() tries to open the link', () => { - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - - act(() => { - instance.setState({ verifying: true, tokenInput: 'hello' }); - }); + const { instance } = createDialog(); + setDialogState(instance, { verifying: true, tokenInput: 'hello' }); instance.openGenerateTokenExternal(); expect(window.open).toHaveBeenCalled(); @@ -160,13 +177,7 @@ describe('TokenDialog component', () => { }); it('handles missing input', async () => { - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - act(() => { - instance.setState({ tokenInput: '' }); - }); + const { instance } = createDialog(); await instance.onSubmitToken(); @@ -175,15 +186,8 @@ describe('TokenDialog component', () => { }); it('tries to sign the user in', async () => { - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - act(() => { - instance.setState({ tokenInput: mockValidToken }); - }); - - await instance.onSubmitToken(); + const { instance } = createDialog(); + await submitToken(instance); expect(window.ElectronFiddle.gitHubSignIn).toHaveBeenCalledWith( mockValidToken, @@ -194,54 +198,15 @@ describe('TokenDialog component', () => { }); it('handles an invalid token error', async () => { - vi.mocked(window.ElectronFiddle.gitHubSignIn).mockResolvedValueOnce({ - success: false, - error: 'Invalid GitHub token. Please check your token and try again.', - }); - - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - act(() => { - instance.setState({ tokenInput: mockValidToken }); - }); - - await act(async () => { - await instance.onSubmitToken(); - }); - - expect(instance.state.error).toBe(true); - expect(instance.state.errorMessage).toBe( + await expectSubmitError( 'Invalid GitHub token. Please check your token and try again.', ); - expect(store.gitHubLogin).toEqual(null); }); it('surfaces the missing-gist-scope error from the main process', async () => { - vi.mocked(window.ElectronFiddle.gitHubSignIn).mockResolvedValueOnce({ - success: false, - error: - 'Token is missing the "gist" scope. Please generate a new token with gist permissions.', - }); - - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - act(() => { - instance.setState({ tokenInput: mockValidToken }); - }); - - await act(async () => { - await instance.onSubmitToken(); - }); - - expect(instance.state.error).toBe(true); - expect(instance.state.errorMessage).toBe( + await expectSubmitError( 'Token is missing the "gist" scope. Please generate a new token with gist permissions.', ); - expect(store.gitHubLogin).toEqual(null); }); }); }); From a156489763f00bc6c9fa4323ac23b6904d9caf31 Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 15:44:09 -0500 Subject: [PATCH 12/18] chore: make linter happy --- src/renderer/app.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 9fddb66ae2..b07b259021 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -165,11 +165,13 @@ export class App { // Wait for auth restore before signalling ready so that queued IPC // messages (e.g. deep-linked private gist loads) use the authenticated // Octokit instance. - window.ElectronFiddle.gitHubCheckAuth().then(({ login }) => { - this.state.gitHubLogin = login; - }).finally(() => { - window.ElectronFiddle.sendReady(); - }); + window.ElectronFiddle.gitHubCheckAuth() + .then(({ login }) => { + this.state.gitHubLogin = login; + }) + .finally(() => { + window.ElectronFiddle.sendReady(); + }); window.ElectronFiddle.addEventListener('set-show-me-template', () => { window.ElectronFiddle.setShowMeTemplate(this.state.templateName); From 9e2f014f84c26937d84c5bc22016bb21b0a77ed8 Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 15:45:57 -0500 Subject: [PATCH 13/18] chore: fix tsc typing error --- tests/renderer/components/dialog-token.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/renderer/components/dialog-token.spec.tsx b/tests/renderer/components/dialog-token.spec.tsx index df3810a1e5..c85c77ce26 100644 --- a/tests/renderer/components/dialog-token.spec.tsx +++ b/tests/renderer/components/dialog-token.spec.tsx @@ -38,7 +38,7 @@ describe('TokenDialog component', () => { nextState: Partial, ) { act(() => { - instance.setState(nextState); + instance.setState(nextState as TokenDialogInstance['state']); }); } From 13e2fc2b560412e95d5f7b534865c1ab4a0f2188 Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 21:08:08 -0500 Subject: [PATCH 14/18] test: use more production code, fewer mocks in github.spec.ts --- tests/main/github.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/main/github.spec.ts b/tests/main/github.spec.ts index d6b35f69e1..339d374dc5 100644 --- a/tests/main/github.spec.ts +++ b/tests/main/github.spec.ts @@ -121,7 +121,6 @@ describe('github', () => { beforeEach(async () => { userDataPath = tmp.dirSync({ prefix: 'electron-fiddle-github-' }); app.setPath('userData', userDataPath); - // Confirm that the folder we just created will hold the credentials file expect(getCredentialsPath().startsWith(userDataPath)).toBe(true); expect(loadToken()).toBeNull(); @@ -137,9 +136,10 @@ describe('github', () => { mockOctokitInstance(); expect(loadToken()).toBeNull(); + const expectedSignInResult = { success: true, login: MOCK_LOGIN }; for (const token of [VALID_GHP_TOKEN, VALID_PAT_TOKEN]) { const result = await handleTokenSignIn(MOCK_EVENT, token); - expect(result).toEqual({ success: true, login: MOCK_LOGIN }); + expect(result).toEqual(expectedSignInResult); const encrypted = safeStorage.encryptString(token); expect(loadToken()).toBe(token); From 8e500e6329366a8d40e269857242a05d9362b7fe Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 8 May 2026 21:49:35 -0500 Subject: [PATCH 15/18] refactor: minor copyediting --- tests/main/github.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/main/github.spec.ts b/tests/main/github.spec.ts index 339d374dc5..27db167092 100644 --- a/tests/main/github.spec.ts +++ b/tests/main/github.spec.ts @@ -136,10 +136,9 @@ describe('github', () => { mockOctokitInstance(); expect(loadToken()).toBeNull(); - const expectedSignInResult = { success: true, login: MOCK_LOGIN }; for (const token of [VALID_GHP_TOKEN, VALID_PAT_TOKEN]) { const result = await handleTokenSignIn(MOCK_EVENT, token); - expect(result).toEqual(expectedSignInResult); + expect(result).toEqual({ success: true, login: MOCK_LOGIN }); const encrypted = safeStorage.encryptString(token); expect(loadToken()).toBe(token); From 56e7ea5969de73f4d0b1db1b632d4c9e584c6834 Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 22 May 2026 11:01:55 -0500 Subject: [PATCH 16/18] fixup! chore: code cleanup --- tests/main/github.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/main/github.spec.ts b/tests/main/github.spec.ts index 27db167092..2f1f44ff5c 100644 --- a/tests/main/github.spec.ts +++ b/tests/main/github.spec.ts @@ -213,9 +213,7 @@ describe('github', () => { // setup: set a token & confirm it loads saveToken(VALID_GHP_TOKEN); expect(loadToken()).toBe(VALID_GHP_TOKEN); - - const expected = { success: true }; - await expect(handleTokenSignOut(MOCK_EVENT)).resolves.toEqual(expected); + await expect(handleTokenSignOut(MOCK_EVENT)).resolves.toBeUndefined(); expect(loadToken()).toBeNull(); }); }); From 813c174eebd0a54d21c75a39fbd520a14e553ecc Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 22 May 2026 11:58:02 -0500 Subject: [PATCH 17/18] chore: reduce unnecessary diffs with main --- .../commands-publish-button.spec.tsx | 82 ++++---- .../renderer/components/dialog-token.spec.tsx | 191 ++++++++++-------- 2 files changed, 149 insertions(+), 124 deletions(-) diff --git a/tests/renderer/components/commands-publish-button.spec.tsx b/tests/renderer/components/commands-publish-button.spec.tsx index 0047b77acb..7338f9433b 100644 --- a/tests/renderer/components/commands-publish-button.spec.tsx +++ b/tests/renderer/components/commands-publish-button.spec.tsx @@ -5,8 +5,6 @@ import { EditorValues, GistActionState, GistActionType, - GistCreateParams, - GistFile, MAIN_JS, } from '../../../src/interfaces'; import { App } from '../../../src/renderer/app'; @@ -15,14 +13,20 @@ import { AppState } from '../../../src/renderer/state'; import { createEditorValues } from '../../mocks/mocks'; import { renderClassComponentWithInstanceRef } from '../utils/renderClassComponentWithInstanceRef'; +type GistFile = { filename: string; content: string }; type GistFiles = { [id: string]: GistFile }; +type GistCreateOpts = { + description: string; + files: GistFiles; + isPublic: boolean; +}; describe('Action button component', () => { const description = 'Electron Fiddle Gist'; const errorMessage = '💀'; let app: App; let state: AppState; - let expectedGistOpts: GistCreateParams; + let expectedGistOpts: GistCreateOpts; function getGistFiles(values: EditorValues): GistFiles { return Object.fromEntries( @@ -67,30 +71,6 @@ describe('Action button component', () => { }); } - type ActionButtonInstance = ReturnType['instance']; - - function createModeActionButton( - actionType: GistActionType, - gistId = '123', - ): ActionButtonInstance { - state.gistId = gistId; - const { instance } = createActionButton(); - act(() => instance.setState({ actionType })); - return instance; - } - - async function performWithDescription( - instance: ActionButtonInstance, - nextDescription: string | undefined, - ) { - state.showInputDialog = vi.fn().mockResolvedValueOnce(nextDescription); - await instance.performGistAction(); - } - - async function performWithDefaultDescription(instance: ActionButtonInstance) { - await performWithDescription(instance, description); - } - it('renders', () => { const { renderResult } = createActionButton(); const button = renderResult.getByTestId('button-action'); @@ -166,7 +146,8 @@ describe('Action button component', () => { }); it('publishes a gist', async () => { - await performWithDefaultDescription(instance); + state.showInputDialog = vi.fn().mockResolvedValueOnce(description); + await instance.performGistAction(); expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith( expectedGistOpts, ); @@ -176,7 +157,8 @@ describe('Action button component', () => { (state.editorMosaic as any).currentHashes = new Map([ [MAIN_JS, 'abc123'], ]); - await performWithDefaultDescription(instance); + state.showInputDialog = vi.fn().mockResolvedValueOnce(description); + await instance.performGistAction(); expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith( expectedGistOpts, ); @@ -184,16 +166,18 @@ describe('Action button component', () => { }); it('asks the user for a description', async () => { - const nextDescription = 'some non-default description'; - await performWithDescription(instance, nextDescription); + const description = 'some non-default description'; + state.showInputDialog = vi.fn().mockResolvedValueOnce(description); + await instance.performGistAction(); expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith({ ...expectedGistOpts, - description: nextDescription, + description, }); }); it('publishes only if the user confirms', async () => { - await performWithDescription(instance, undefined); + state.showInputDialog = vi.fn().mockResolvedValueOnce(undefined); + await instance.performGistAction(); expect(window.ElectronFiddle.gistCreate).not.toHaveBeenCalled(); }); @@ -202,7 +186,8 @@ describe('Action button component', () => { const values = { [MAIN_JS]: '' } as const; vi.mocked(app.getEditorValues).mockResolvedValueOnce(values); - await performWithDefaultDescription(instance); + state.showInputDialog = vi.fn().mockResolvedValueOnce(description); + await instance.performGistAction(); const files = getGistFiles(values); const expected = { ...expectedGistOpts, files }; @@ -217,7 +202,8 @@ describe('Action button component', () => { ...required, ...optional, }); - await performWithDefaultDescription(instance); + state.showInputDialog = vi.fn().mockResolvedValueOnce(description); + await instance.performGistAction(); const files = getGistFiles(required); const expected = { ...expectedGistOpts, files }; @@ -240,15 +226,18 @@ describe('Action button component', () => { new Error(errorMessage), ); - await performWithDefaultDescription(instance); + state.showInputDialog = vi.fn().mockResolvedValueOnce(description); + + await instance.performGistAction(); expect(state.activeGistAction).toBe(GistActionState.none); // On failure the editor should still be considered edited }); it('can publish secret gists', async () => { + state.showInputDialog = vi.fn().mockResolvedValueOnce(description); instance.setSecret(); - await performWithDefaultDescription(instance); + await instance.performGistAction(); expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith({ ...expectedGistOpts, isPublic: false, @@ -256,8 +245,9 @@ describe('Action button component', () => { }); it('can publish public gists', async () => { + state.showInputDialog = vi.fn().mockResolvedValueOnce(description); instance.setPublic(); - await performWithDefaultDescription(instance); + await instance.performGistAction(); expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith({ ...expectedGistOpts, isPublic: true, @@ -272,7 +262,8 @@ describe('Action button component', () => { revision: revisionSha, }); - await performWithDefaultDescription(instance); + state.showInputDialog = vi.fn().mockResolvedValueOnce(description); + await instance.performGistAction(); expect(state.activeGistRevision).toBe(revisionSha); }); @@ -284,7 +275,9 @@ describe('Action button component', () => { beforeEach(() => { // create a button that's primed to update gistId - instance = createModeActionButton(GistActionType.update, gistId); + state.gistId = gistId; + ({ instance } = createActionButton()); + act(() => instance.setState({ actionType: GistActionType.update })); vi.mocked(window.ElectronFiddle.gistUpdate).mockResolvedValue({ id: gistId, @@ -293,7 +286,7 @@ describe('Action button component', () => { }); }); - it('attempts to update an existing Gist via IPC', async () => { + it('attempts to update an existing Gist', async () => { await instance.performGistAction(); expect(window.ElectronFiddle.gistUpdate).toHaveBeenCalledWith({ @@ -336,11 +329,14 @@ describe('Action button component', () => { let instance: any; beforeEach(() => { + state.gistId = gistId; + // create a button primed to delete gistId - instance = createModeActionButton(GistActionType.delete, gistId); + ({ instance } = createActionButton()); + act(() => instance.setState({ actionType: GistActionType.delete })); }); - it('attempts to delete an existing Gist via IPC', async () => { + it('attempts to delete an existing Gist', async () => { await instance.performGistAction(); expect(window.ElectronFiddle.gistDelete).toHaveBeenCalledWith(gistId); }); diff --git a/tests/renderer/components/dialog-token.spec.tsx b/tests/renderer/components/dialog-token.spec.tsx index c85c77ce26..4e23a208aa 100644 --- a/tests/renderer/components/dialog-token.spec.tsx +++ b/tests/renderer/components/dialog-token.spec.tsx @@ -11,65 +11,8 @@ import { renderClassComponentWithInstanceRef } from '../utils/renderClassCompone describe('TokenDialog component', () => { const mockValidToken = 'ghp_muuHkYenGrOHrTBQKDALW8WtSD929EXMz63n'; const mockInvalidToken = 'testtoken'; - const resetState = { - verifying: false, - error: false, - errorMessage: undefined, - tokenInput: '', - }; let store: AppState; - function renderDialog() { - store.isTokenDialogShowing = true; - return render(); - } - - function createDialog() { - store.isTokenDialogShowing = true; - return renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - } - - type TokenDialogInstance = ReturnType['instance']; - - function setDialogState( - instance: TokenDialogInstance, - nextState: Partial, - ) { - act(() => { - instance.setState(nextState as TokenDialogInstance['state']); - }); - } - - function expectResetState(instance: TokenDialogInstance) { - expect(instance.state).toEqual(resetState); - } - - async function submitToken( - instance: TokenDialogInstance, - token = mockValidToken, - ) { - setDialogState(instance, { tokenInput: token }); - await act(async () => { - await instance.onSubmitToken(); - }); - } - - async function expectSubmitError(errorMessage: string) { - vi.mocked(window.ElectronFiddle.gitHubSignIn).mockResolvedValueOnce({ - success: false, - error: errorMessage, - }); - - const { instance } = createDialog(); - await submitToken(instance); - - expect(instance.state.error).toBe(true); - expect(instance.state.errorMessage).toBe(errorMessage); - expect(store.gitHubLogin).toBeNull(); - } - beforeEach(() => { // We render the buttons different depending on the // platform, so let' have a uniform platform for unit tests @@ -79,7 +22,8 @@ describe('TokenDialog component', () => { }); it('renders', () => { - renderDialog(); + store.isTokenDialogShowing = true; + render(); expect(screen.getByText('GitHub Token')).toBeInTheDocument(); expect(screen.getByText('Done')).toBeInTheDocument(); @@ -90,7 +34,10 @@ describe('TokenDialog component', () => { }); it('tries to read the clipboard on focus and enters it if valid', async () => { - const { instance } = createDialog(); + store.isTokenDialogShowing = true; + const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { + appState: store, + }); vi.mocked(window.navigator.clipboard.readText).mockResolvedValueOnce( mockValidToken, @@ -104,7 +51,10 @@ describe('TokenDialog component', () => { }); it('tries to read the clipboard on focus and does not enter it if invalid', async () => { - const { instance } = createDialog(); + store.isTokenDialogShowing = true; + const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { + appState: store, + }); vi.mocked(window.navigator.clipboard.readText).mockResolvedValueOnce( mockInvalidToken, @@ -118,36 +68,61 @@ describe('TokenDialog component', () => { }); it('reset() resets the component', () => { - const { instance } = createDialog(); - setDialogState(instance, { - verifying: true, - tokenInput: 'hello', - errorMessage: 'test error', + store.isTokenDialogShowing = true; + const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { + appState: store, + }); + act(() => { + instance.setState({ + verifying: true, + tokenInput: 'hello', + errorMessage: 'test error', + }); }); act(() => { instance.reset(); }); - expectResetState(instance); + expect(instance.state).toEqual({ + verifying: false, + error: false, + errorMessage: undefined, + tokenInput: '', + }); }); it('onClose() resets the component', () => { - const { instance } = createDialog(); - setDialogState(instance, { - verifying: true, - tokenInput: 'hello', - errorMessage: 'test error', + store.isTokenDialogShowing = true; + const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { + appState: store, + }); + act(() => { + instance.setState({ + verifying: true, + tokenInput: 'hello', + errorMessage: 'test error', + }); }); act(() => { instance.onClose(); }); - expectResetState(instance); + expect(instance.state).toEqual({ + verifying: false, + error: false, + errorMessage: undefined, + tokenInput: '', + }); }); it('handleChange() handles the change event', () => { - const { instance } = createDialog(); - setDialogState(instance, { verifying: true, tokenInput: 'hello' }); + store.isTokenDialogShowing = true; + const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { + appState: store, + }); + act(() => { + instance.setState({ verifying: true, tokenInput: 'hello' }); + }); act(() => { instance.handleChange({ @@ -159,8 +134,13 @@ describe('TokenDialog component', () => { }); it('openGenerateTokenExternal() tries to open the link', () => { - const { instance } = createDialog(); - setDialogState(instance, { verifying: true, tokenInput: 'hello' }); + store.isTokenDialogShowing = true; + const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { + appState: store, + }); + act(() => { + instance.setState({ verifying: true, tokenInput: 'hello' }); + }); instance.openGenerateTokenExternal(); expect(window.open).toHaveBeenCalled(); @@ -177,7 +157,10 @@ describe('TokenDialog component', () => { }); it('handles missing input', async () => { - const { instance } = createDialog(); + store.isTokenDialogShowing = true; + const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { + appState: store, + }); await instance.onSubmitToken(); @@ -186,8 +169,15 @@ describe('TokenDialog component', () => { }); it('tries to sign the user in', async () => { - const { instance } = createDialog(); - await submitToken(instance); + store.isTokenDialogShowing = true; + const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { + appState: store, + }); + act(() => { + instance.setState({ tokenInput: mockValidToken }); + }); + + await act(() => instance.onSubmitToken()); expect(window.ElectronFiddle.gitHubSignIn).toHaveBeenCalledWith( mockValidToken, @@ -198,15 +188,54 @@ describe('TokenDialog component', () => { }); it('handles an invalid token error', async () => { - await expectSubmitError( + vi.mocked(window.ElectronFiddle.gitHubSignIn).mockResolvedValueOnce({ + success: false, + error: 'Invalid GitHub token. Please check your token and try again.', + }); + + store.isTokenDialogShowing = true; + const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { + appState: store, + }); + act(() => { + instance.setState({ tokenInput: mockValidToken }); + }); + + await act(async () => { + await instance.onSubmitToken(); + }); + + expect(instance.state.error).toBe(true); + expect(instance.state.errorMessage).toBe( 'Invalid GitHub token. Please check your token and try again.', ); + expect(store.gitHubLogin).toBeNull(); }); it('surfaces the missing-gist-scope error from the main process', async () => { - await expectSubmitError( + vi.mocked(window.ElectronFiddle.gitHubSignIn).mockResolvedValueOnce({ + success: false, + error: + 'Token is missing the "gist" scope. Please generate a new token with gist permissions.', + }); + + store.isTokenDialogShowing = true; + const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { + appState: store, + }); + act(() => { + instance.setState({ tokenInput: mockValidToken }); + }); + + await act(async () => { + await instance.onSubmitToken(); + }); + + expect(instance.state.error).toBe(true); + expect(instance.state.errorMessage).toBe( 'Token is missing the "gist" scope. Please generate a new token with gist permissions.', ); + expect(store.gitHubLogin).toBeNull(); }); }); }); From 04242d5d763e7288651fda1b8839751a260b0fe2 Mon Sep 17 00:00:00 2001 From: Charles Kerr Date: Fri, 22 May 2026 12:08:09 -0500 Subject: [PATCH 18/18] chore: make prettier happy --- src/main/github.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/github.ts b/src/main/github.ts index 628aca8e3e..3d4e507dd2 100644 --- a/src/main/github.ts +++ b/src/main/github.ts @@ -6,8 +6,20 @@ import { IpcMainInvokeEvent, app, safeStorage } from 'electron'; import { getTemplate } from './content'; import { ipcMainManager } from './ipc'; -import { GIST_MAX_FILE_COUNT, GIST_MAX_FILE_SIZE, GITHUB_TOKEN_PATTERN } from '../constants'; -import { EditorValues, GistFile, GistLoadResult, GistRevision, GistWriteResult, GitHubCheckAuthResult, GitHubSignInResult } from '../interfaces'; +import { + GIST_MAX_FILE_COUNT, + GIST_MAX_FILE_SIZE, + GITHUB_TOKEN_PATTERN, +} from '../constants'; +import { + EditorValues, + GistFile, + GistLoadResult, + GistRevision, + GistWriteResult, + GitHubCheckAuthResult, + GitHubSignInResult, +} from '../interfaces'; import { IpcEvents } from '../ipc-events'; import { isSupportedFile } from '../utils/editor-utils';