diff --git a/src/ambient.d.ts b/src/ambient.d.ts index fdc7fa472d..96dd10cac8 100644 --- a/src/ambient.d.ts +++ b/src/ambient.d.ts @@ -7,9 +7,14 @@ import { FiddleEvent, FileTransformOperation, Files, + GistCreateParams, GistLoadParams, GistLoadResult, GistRevision, + GistUpdateParams, + GistWriteResult, + GitHubCheckAuthResult, + GitHubSignInResult, IPackageManager, InstallState, InstallStateEvent, @@ -107,8 +112,14 @@ 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; + gitHubCheckAuth(): Promise; + gitHubSignIn(token: string): Promise; + gitHubSignOut(): Promise; getAvailableThemes(): Promise>; getElectronTypes(ver: RunnableVersion): Promise; getIsPackageManagerInstalled( diff --git a/src/constants.ts b/src/constants.ts index 4652758562..1106c36d14 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,3 +2,14 @@ export const SENTRY_DSN = 'https://966a5b01ac8d4941b81e4ebd0ab4c991@sentry.io/1882540'; export const ELECTRON_DTS = 'electron.d.ts'; + +// 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})$/; + +// GitHub gist limits. Enforced when creating/updating a gist so we fail +// fast in main instead of round-tripping to the API. Shared with tests. +export const GIST_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB per file +export const GIST_MAX_FILE_COUNT = 300; diff --git a/src/interfaces.ts b/src/interfaces.ts index 8a343a3d47..b1b72b92e0 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -217,17 +217,48 @@ export interface GistRevision { }; } +export interface GistCreateParams { + description: string; + files: Record; + isPublic: boolean; +} + +export interface GistFile { + filename: string; + content: string; +} + export interface GistLoadParams { gistId: string; revision?: string; } export interface GistLoadResult { - files: Record; + files: Record; + revision?: string; +} + +export interface GistUpdateParams { + gistId: string; + files: Record; +} + +export interface GistWriteResult { id: string; + url: string; revision?: string; } +export interface GitHubSignInResult { + success: boolean; + login?: string; + error?: string; +} + +export interface GitHubCheckAuthResult { + login: string | null; +} + export enum GlobalSetting { acceleratorsToBlock = 'acceleratorsToBlock', channelsToShow = 'channelsToShow', diff --git a/src/main/constants.ts b/src/main/constants.ts index da1a23777a..7838631fac 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -1,8 +1,21 @@ +import * as fs from 'node:fs'; import * as path from 'node:path'; import { app } from 'electron'; -export const STATIC_DIR = path.resolve(__dirname, '../static'); +// Find the root dir for static assets (eg `show-me/`, `electron-quick-start/`). +// In production, the bundled main script lives in `.webpack/main/` and webpack +// copies static assets to `.webpack/static/`. +// In tests (vitest loads the source TypeScript directly), `__dirname` is +// `src/main/` and the static folder lives at the repository root. +function resolveStaticDir(): string { + const paths = ['../static', '../../static'].map((p) => + path.resolve(__dirname, p), + ); + return paths.find(fs.existsSync) ?? paths[0]; +} + +export const STATIC_DIR = resolveStaticDir(); export const ELECTRON_DOWNLOAD_PATH = path.join( app.getPath('userData'), diff --git a/src/main/github.ts b/src/main/github.ts index aaee6a6b61..3d4e507dd2 100644 --- a/src/main/github.ts +++ b/src/main/github.ts @@ -6,7 +6,20 @@ import { IpcMainInvokeEvent, app, safeStorage } from 'electron'; import { getTemplate } from './content'; import { ipcMainManager } from './ipc'; -import { EditorValues, GistLoadResult, GistRevision } 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'; @@ -16,21 +29,14 @@ 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}$/; const MAX_DESCRIPTION_LENGTH = 256; -const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB per file — GitHub's gist limit - -const MAX_FILE_COUNT = 300; // GitHub's gist file limit - 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 { @@ -49,11 +55,6 @@ function isValidDescription(description: unknown): description is string { ); } -interface GistFile { - filename: string; - content: string; -} - function areValidGistFiles( files: unknown, ): files is Record { @@ -62,7 +63,8 @@ function areValidGistFiles( const entries = Object.entries(files as Record); - if (entries.length === 0 || entries.length > MAX_FILE_COUNT) return false; + if (entries.length === 0 || entries.length > GIST_MAX_FILE_COUNT) + return false; for (const [key, value] of entries) { // null entries are used to delete files during update @@ -75,7 +77,7 @@ function areValidGistFiles( if (filename.length === 0) return false; if (filename !== key) return false; if (typeof content !== 'string') return false; - if (content.length > MAX_FILE_SIZE) return false; + if (content.length > GIST_MAX_FILE_SIZE) return false; } return true; @@ -125,16 +127,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.' }; @@ -163,6 +159,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.', @@ -170,21 +167,14 @@ 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 { - login: string | null; } async function handleTokenCheckAuth( _event: IpcMainInvokeEvent, -): Promise { +): Promise { const token = loadToken(); if (!token) return { login: null }; @@ -203,12 +193,6 @@ async function handleTokenCheckAuth( } } -interface GistWriteResult { - id: string; - url: string; - revision?: string; -} - async function handleGistCreate( _event: IpcMainInvokeEvent, params: unknown, @@ -256,14 +240,16 @@ async function handleGistUpdate( // Fetch existing files to detect deletions const { data: existing } = await octo.gists.get({ gist_id: gistId }); - const updateFiles = { ...(files as Record) }; - for (const id of Object.keys(existing.files ?? {})) { - if (!(id in updateFiles)) updateFiles[id] = null as any; + const updateFiles: Record = { ...files }; + for (const fileId of Object.keys(existing.files ?? {})) { + if (!(fileId in updateFiles)) updateFiles[fileId] = null; } const gist = await octo.gists.update({ gist_id: gistId, - files: updateFiles as any, + // 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 { @@ -276,12 +262,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( @@ -303,7 +288,7 @@ async function handleGistLoad( : await octo.gists.get({ gist_id: gistId }); 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. @@ -316,15 +301,14 @@ async function handleGistLoad( } } - files[id] = { - filename: data.filename ?? id, + files[fileId] = { + filename: data.filename ?? fileId, content, }; } return { files, - id: gist.data.id!, revision: gist.data.history?.[0]?.version, }; } @@ -437,6 +421,7 @@ export function setupGitHub() { // Exported for testing export const testing = { fetchExample, + getCredentialsPath, handleGistCreate, handleGistDelete, handleGistListCommits, @@ -445,4 +430,6 @@ export const testing = { handleTokenCheckAuth, handleTokenSignIn, handleTokenSignOut, + loadToken, + saveToken, }; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index ede8f82153..df6e073755 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -7,7 +7,9 @@ import { FiddleEvent, FileTransformOperation, Files, + GistCreateParams, GistLoadParams, + GistUpdateParams, IPackageManager, MessageOptions, PMOperationOptions, @@ -110,10 +112,21 @@ 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), + 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..b07b259021 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -161,7 +161,17 @@ export class App { this.setupUnloadListeners(); this.setupTypeListeners(); - window.ElectronFiddle.sendReady(); + // Restore signed-in state from main's encrypted credential, if any. + // 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.addEventListener('set-show-me-template', () => { window.ElectronFiddle.setShowMeTemplate(this.state.templateName); diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx index a40159293f..d1e5f8ca45 100644 --- a/src/renderer/components/commands-action-button.tsx +++ b/src/renderer/components/commands-action-button.tsx @@ -22,15 +22,12 @@ import { } from '../../interfaces'; import { AppState } from '../state'; import { ensureRequiredFiles } from '../utils/editor-utils'; -import { getOctokit } from '../utils/octokit'; interface GistActionButtonProps { appState: AppState; } interface IGistActionButtonState { - readonly isUpdating: boolean; - readonly isDeleting: boolean; readonly actionType: GistActionType; } @@ -50,8 +47,6 @@ export const GistActionButton = observer( this.setPublic = this.setPublic.bind(this); this.state = { - isUpdating: false, - isDeleting: false, actionType: GistActionType.publish, }; @@ -85,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(); } } @@ -110,7 +105,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 +120,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 +140,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), }, }); @@ -194,31 +187,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({ + gistId: 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 +214,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), }, }); } @@ -255,17 +238,13 @@ 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 }); this.renderToast({ message: 'Successfully deleted gist!' }); } catch (error: any) { console.warn(`Could not delete gist`, { error }); diff --git a/src/renderer/components/dialog-token.tsx b/src/renderer/components/dialog-token.tsx index 539b3f806a..652a74724e 100644 --- a/src/renderer/components/dialog-token.tsx +++ b/src/renderer/components/dialog-token.tsx @@ -3,8 +3,8 @@ 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'; -import { getOctokit } from '../utils/octokit'; interface TokenDialogProps { appState: AppState; @@ -20,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. @@ -51,75 +49,33 @@ 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; + this.props.appState.gitHubLogin = null; return; } - // Token is valid and has required scopes. - this.props.appState.gitHubToken = this.state.tokenInput; - this.props.appState.gitHubLogin = validation.user.login; + // 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 }); this.props.appState.isTokenDialogShowing = false; @@ -159,7 +115,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 }); } } 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/remote-loader.ts b/src/renderer/remote-loader.ts index 7e96a4d673..d5cbe1dfb6 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({ + gistId, + revision, + }); const values: EditorValues = {}; diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 7481bbbbac..470d913705 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -42,6 +42,12 @@ import { WindowSpecificSetting, } from '../interfaces'; +// 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/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/content.spec.ts b/tests/main/content.spec.ts index 2161c6b0b0..8932d19e0f 100644 --- a/tests/main/content.spec.ts +++ b/tests/main/content.spec.ts @@ -20,9 +20,6 @@ import { getTemplate, getTestTemplate } from '../../src/main/content'; import { isReleasedMajor } from '../../src/main/versions'; vi.unmock('fs-extra'); -vi.mock('../../src/main/constants', () => ({ - STATIC_DIR: path.join(__dirname, '../../static'), -})); vi.mock('../../src/main/versions', () => ({ isReleasedMajor: vi.fn(), })); diff --git a/tests/main/github.spec.ts b/tests/main/github.spec.ts index 6bbc0c045e..8e727cadc2 100644 --- a/tests/main/github.spec.ts +++ b/tests/main/github.spec.ts @@ -1,13 +1,16 @@ import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { IpcMainInvokeEvent, safeStorage } from 'electron'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { IpcMainInvokeEvent, app, safeStorage } from 'electron'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { GIST_MAX_FILE_COUNT, GIST_MAX_FILE_SIZE } from '../../src/constants'; +import { getTemplate } from '../../src/main/content'; import { testing } from '../../src/main/github'; +import * as tmp from '../../src/main/utils/tmp'; const { fetchExample, + getCredentialsPath, handleGistCreate, handleGistDelete, handleGistListCommits, @@ -16,30 +19,59 @@ const { handleTokenCheckAuth, handleTokenSignIn, handleTokenSignOut, + loadToken, + saveToken, } = testing; -vi.mock('node:fs', () => ({ - existsSync: vi.fn(() => false), - readFileSync: vi.fn(() => Buffer.from('')), - writeFileSync: vi.fn(), - unlinkSync: vi.fn(), -})); vi.mock('@octokit/rest', () => { const MockOctokit = vi.fn(); return { Octokit: MockOctokit }; }); -vi.mock('../../src/main/content', () => ({ - getTemplate: vi.fn(), -})); - -const { getTemplate } = await import('../../src/main/content'); +vi.unmock('fs-extra'); const MOCK_EVENT = {} as IpcMainInvokeEvent; // Import Octokit so we can mock its constructor const { Octokit } = await import('@octokit/rest'); +const VALID_GHP_TOKEN = 'ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh12'; +const VALID_PAT_TOKEN = + 'github_pat_ABCDEFGHIJKLMNOPQRSTUV_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzABCDEFG'; +const VALID_GIST_ID = 'abc123def456abc123def456abc123de'; +const VALID_SHA = 'abc123def456abc123def456abc123deabc123de'; +const INVALID_GIST_IDS = [ + 'bad-id', + 123, + null, + undefined, + 'abc123', + 'a'.repeat(33), + 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz', + 'https://gist.github.com/abc123def456abc123def456abc123de', +]; +const MOCK_LOGIN = 'testuser'; +const VALID_FILES = { + 'main.js': { filename: 'main.js', content: 'code' }, +}; +let userDataPath: string; + function mockOctokitInstance(overrides: Record = {}) { + const MOCK_GIST_FILES = { + 'main.js': { + filename: 'main.js', + content: 'console.log("hi")', + truncated: false, + raw_url: 'https://raw.example.com/main.js', + }, + }; + + const MOCK_GIST_DATA = { + id: VALID_GIST_ID, + html_url: `https://gist.github.com/${VALID_GIST_ID}`, + history: [{ version: 'sha1' }], + files: MOCK_GIST_FILES, + }; + const instance = { users: { getAuthenticated: vi.fn().mockResolvedValue({ @@ -85,67 +117,31 @@ function mockOctokitInstance(overrides: Record = {}) { return instance; } -const VALID_GHP_TOKEN = 'ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh12'; -const VALID_PAT_TOKEN = - 'github_pat_ABCDEFGHIJKLMNOPQRSTUV_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzABCDEFG'; -const VALID_GIST_ID = 'abc123def456abc123def456abc123de'; -const VALID_SHA = 'abc123def456abc123def456abc123deabc123de'; -const INVALID_GIST_IDS = [ - 'bad-id', - 123, - null, - undefined, - 'abc123', - 'a'.repeat(33), - 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz', - 'https://gist.github.com/abc123def456abc123def456abc123de', -]; -const MOCK_LOGIN = 'testuser'; -const CREDENTIALS_FILE = '.github-credentials'; -const VALID_FILES = { - 'main.js': { filename: 'main.js', content: 'code' }, -}; - -const MOCK_GIST_FILES = { - 'main.js': { - filename: 'main.js', - content: 'console.log("hi")', - truncated: false, - raw_url: 'https://raw.example.com/main.js', - }, -}; - -const MOCK_GIST_DATA = { - id: VALID_GIST_ID, - html_url: `https://gist.github.com/${VALID_GIST_ID}`, - history: [{ version: 'sha1' }], - files: MOCK_GIST_FILES, -}; - describe('github', () => { - beforeEach(() => { - vi.mocked(fs.existsSync).mockReturnValue(false); + beforeEach(async () => { + userDataPath = tmp.dirSync({ prefix: 'electron-fiddle-github-' }); + app.setPath('userData', userDataPath); // Reset module-level octokit state by signing out - handleTokenSignOut(MOCK_EVENT); + await handleTokenSignOut(MOCK_EVENT); }); - // --- Token sign-in --- + afterEach(async () => { + await handleTokenSignOut(MOCK_EVENT); + fs.rmSync(userDataPath, { recursive: true, force: true }); + }); describe('handleTokenSignIn()', () => { - it('signs in with valid token formats', async () => { + it('saves encrypted tokens to a permission-protected userData file', async () => { mockOctokitInstance(); - 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 }); - const lastWriteCall = vi.mocked(fs.writeFileSync).mock.calls.at(-1); - const writePath = lastWriteCall?.[0]; - expect(writePath).toBe(path.join('/Users/fake-user', CREDENTIALS_FILE)); - expect(lastWriteCall?.[2]).toEqual({ mode: 0o600 }); + const encrypted = safeStorage.encryptString(token); + expect(fs.readFileSync(getCredentialsPath())).toEqual(encrypted); + expect(fs.statSync(getCredentialsPath()).mode & 0o777).toBe(0o600); + expect(loadToken()).toBe(token); } - - expect(fs.writeFileSync).toHaveBeenCalled(); }); it('rejects an invalid token format', async () => { @@ -207,61 +203,43 @@ describe('github', () => { }); }); - // --- Token sign-out --- - describe('handleTokenSignOut()', () => { it('deletes the stored token', async () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - const result = await handleTokenSignOut(MOCK_EVENT); - expect(result).toEqual({ success: true }); - expect(fs.unlinkSync).toHaveBeenCalled(); + saveToken(VALID_GHP_TOKEN); + await expect(handleTokenSignOut(MOCK_EVENT)).resolves.toBeUndefined(); + expect(loadToken()).toBeNull(); }); it('does nothing when the token file does not exist', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - await handleTokenSignOut(MOCK_EVENT); - - expect(fs.unlinkSync).not.toHaveBeenCalled(); + expect(loadToken()).toBeNull(); }); }); - // --- Check auth --- - describe('handleTokenCheckAuth()', () => { it('returns null when decryption fails', async () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockImplementation(() => { + saveToken(VALID_GHP_TOKEN); + vi.mocked(safeStorage.decryptString).mockImplementationOnce(() => { throw new Error('corrupt'); }); - const result = await handleTokenCheckAuth(MOCK_EVENT); - expect(result).toEqual({ login: null }); }); it('returns login when a valid token is stored', async () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue( - Buffer.from(`encrypted:${VALID_GHP_TOKEN}`), - ); + saveToken(VALID_GHP_TOKEN); mockOctokitInstance(); - const result = await handleTokenCheckAuth(MOCK_EVENT); expect(result).toEqual({ login: MOCK_LOGIN }); }); it('returns null when no token is stored', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); const result = await handleTokenCheckAuth(MOCK_EVENT); expect(result).toEqual({ login: null }); }); it('cleans up and returns null for expired tokens', async () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue( - Buffer.from(`encrypted:${VALID_GHP_TOKEN}`), - ); + saveToken(VALID_GHP_TOKEN); mockOctokitInstance({ users: { getAuthenticated: vi @@ -274,14 +252,11 @@ describe('github', () => { const result = await handleTokenCheckAuth(MOCK_EVENT); expect(result).toEqual({ login: null }); - expect(fs.unlinkSync).toHaveBeenCalled(); + expect(loadToken()).toBeNull(); }); it('preserves the token for transient auth-check failures', async () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue( - Buffer.from(`encrypted:${VALID_GHP_TOKEN}`), - ); + saveToken(VALID_GHP_TOKEN); mockOctokitInstance({ users: { getAuthenticated: vi.fn().mockRejectedValue(new Error('offline')), @@ -291,7 +266,7 @@ describe('github', () => { const result = await handleTokenCheckAuth(MOCK_EVENT); expect(result).toEqual({ login: null }); - expect(fs.unlinkSync).not.toHaveBeenCalled(); + expect(loadToken()).toBe(VALID_GHP_TOKEN); }); }); @@ -301,7 +276,6 @@ describe('github', () => { await handleTokenSignIn(MOCK_EVENT, VALID_GHP_TOKEN); // Clear call counts from sign-in so they don't leak into assertions vi.mocked(Octokit).mockClear(); - vi.mocked(fs.writeFileSync).mockClear(); } // --- Gist create --- @@ -354,11 +328,11 @@ describe('github', () => { { 'main.js': { filename: 'main.js', - content: 'x'.repeat(10 * 1024 * 1024 + 1), + content: 'x'.repeat(GIST_MAX_FILE_SIZE + 1), }, }, Object.fromEntries( - Array.from({ length: 301 }, (_, index) => { + Array.from({ length: GIST_MAX_FILE_COUNT + 1 }, (_, index) => { const filename = `file${index}.js`; return [filename, { filename, content: 'x' }]; }), @@ -471,8 +445,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(); } }); @@ -498,7 +473,6 @@ describe('github', () => { gistId: VALID_GIST_ID, }); - expect(result.id).toBe(VALID_GIST_ID); expect(result.files['main.js'].content).toBe('console.log("hi")'); }); @@ -508,7 +482,7 @@ describe('github', () => { revision: VALID_SHA, }); - expect(result.id).toBe(VALID_GIST_ID); + expect(result.revision).toBe('sha1'); }); it('rejects invalid gist IDs', async () => { @@ -535,7 +509,7 @@ describe('github', () => { revision, }); - expect(result.id).toBe(VALID_GIST_ID); + expect(result.revision).toBe('sha1'); } }); @@ -564,11 +538,12 @@ describe('github', () => { const result = await handleGistLoad(MOCK_EVENT, { gistId: 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 () => { - 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); @@ -702,16 +677,9 @@ describe('github', () => { // --- Fetch example --- describe('fetchExample()', () => { - const REF = 'v42.0.0'; + const REF = 'example-template'; const EXAMPLE_PATH = 'docs/fiddles/quick-start'; - const TEMPLATE_VALUES = { - 'main.js': '// template main', - 'index.html': '', - 'renderer.js': '// template renderer', - 'preload.js': '// template preload', - 'styles.css': '/* template css */', - 'package.json': '{}', - }; + const TEMPLATE_VERSION = REF.replace(/^v/, ''); function makeFolderEntry(name: string, downloadUrl: string | null = null) { return { @@ -733,11 +701,6 @@ describe('github', () => { }); } - beforeEach(() => { - vi.mocked(getTemplate).mockReset(); - vi.mocked(getTemplate).mockResolvedValue({ ...TEMPLATE_VALUES }); - }); - it('overlays downloaded supported files onto the template', async () => { const folder = [ makeFolderEntry('main.js'), @@ -751,6 +714,7 @@ describe('github', () => { 'https://example.test/index.html': '', }); + const templateValues = await getTemplate(TEMPLATE_VERSION); const result = await fetchExample(REF, EXAMPLE_PATH); expect(getContent).toHaveBeenCalledWith({ @@ -759,14 +723,12 @@ describe('github', () => { path: EXAMPLE_PATH, ref: REF, }); - // Strips the leading 'v' and forwards the version to getTemplate. - expect(getTemplate).toHaveBeenCalledWith('42.0.0'); // Overridden values come from the example. expect(result['main.js']).toBe('// example main'); expect(result['index.html']).toBe(''); // Files not in the example fall back to template values. - expect(result['renderer.js']).toBe(TEMPLATE_VALUES['renderer.js']); - expect(result['package.json']).toBe(TEMPLATE_VALUES['package.json']); + expect(result['renderer.js']).toBe(templateValues['renderer.js']); + expect(result['package.json']).toBe(templateValues['package.json']); fetchSpy.mockRestore(); }); @@ -865,13 +827,12 @@ describe('github', () => { await expect(fetchExample(REF, '')).rejects.toThrow('Invalid path'); }); - it('returns a fresh object that does not mutate the cached template', async () => { + it('returns a fresh object that does not mutate the template', async () => { const folder = [makeFolderEntry('main.js')]; const getContent = vi.fn().mockResolvedValue({ data: folder }); mockOctokitInstance({ repos: { getContent } }); - const cached = { ...TEMPLATE_VALUES }; - vi.mocked(getTemplate).mockResolvedValue(cached); + const templateValues = await getTemplate(TEMPLATE_VERSION); const fetchSpy = mockFetchResponses({ 'https://example.test/main.js': '// example main', @@ -879,9 +840,10 @@ describe('github', () => { const result = await fetchExample(REF, EXAMPLE_PATH); - expect(result).not.toBe(cached); - expect(cached['main.js']).toBe(TEMPLATE_VALUES['main.js']); + expect(result).not.toBe(templateValues); + expect(templateValues['main.js']).not.toBe('// example main'); expect(result['main.js']).toBe('// example main'); + expect(await getTemplate(TEMPLATE_VERSION)).toEqual(templateValues); fetchSpy.mockRestore(); }); diff --git a/tests/main/templates.spec.ts b/tests/main/templates.spec.ts index 0317e3e817..caeeec1be3 100644 --- a/tests/main/templates.spec.ts +++ b/tests/main/templates.spec.ts @@ -1,5 +1,3 @@ -import * as path from 'node:path'; - import { describe, expect, it, vi } from 'vitest'; import { MAIN_JS } from '../../src/interfaces'; @@ -7,9 +5,6 @@ import { getTemplateValues } from '../../src/main/templates'; import { getEmptyContent } from '../../src/utils/editor-utils'; vi.unmock('fs-extra'); -vi.mock('../../src/main/constants', () => ({ - STATIC_DIR: path.join(__dirname, '../../static'), -})); describe('templates', () => { const KNOWN_GOOD_TEMPLATE = 'clipboard'; diff --git a/tests/mocks/electron-fiddle.ts b/tests/mocks/electron-fiddle.ts index 7f6081a4ae..04fb784b59 100644 --- a/tests/mocks/electron-fiddle.ts +++ b/tests/mocks/electron-fiddle.ts @@ -12,8 +12,14 @@ 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 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/electron.ts b/tests/mocks/electron.ts index 6d87a0a0b9..ef90bba722 100644 --- a/tests/mocks/electron.ts +++ b/tests/mocks/electron.ts @@ -6,6 +6,15 @@ import { BrowserWindowMock } from './browser-window'; import { WebContentsMock } from './web-contents'; const createdNotifications: Array = []; +const defaultMockedPaths: Record = { + home: '~', + userData: '/Users/fake-user', +}; +let mockedPaths: Record = { ...defaultMockedPaths }; + +function resetMockedPaths() { + mockedPaths = { ...defaultMockedPaths }; +} class NotificationMock extends EventEmitter { public readonly show = vi.fn(); @@ -132,11 +141,11 @@ const app = { removedItems: [], })), getLoginItemSettings: vi.fn(), - getPath: vi.fn((name: string) => { - if (name === 'userData') return '/Users/fake-user'; - if (name === 'home') return `~`; - return '/test-path'; + getPath: vi.fn((name: string) => mockedPaths[name]), + setPath: vi.fn((name: string, path: string) => { + mockedPaths[name] = path; }), + _resetMockedPaths: resetMockedPaths, focus: vi.fn(), quit: vi.fn(), relaunch: 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 d244acedc9..0047b77acb 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', @@ -98,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'); @@ -146,7 +139,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(); @@ -154,7 +147,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(); @@ -173,35 +166,35 @@ describe('Action button component', () => { }); it('publishes a gist', async () => { - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); - await instance.performGistAction(); - expect(mocktokit.gists.create).toHaveBeenCalledWith(expectedGistOpts); + await performWithDefaultDescription(instance); + expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith( + expectedGistOpts, + ); }); it('marks the Fiddle as saved', async () => { (state.editorMosaic as any).currentHashes = new Map([ [MAIN_JS, 'abc123'], ]); - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); - await instance.performGistAction(); - expect(mocktokit.gists.create).toHaveBeenCalledWith(expectedGistOpts); + await performWithDefaultDescription(instance); + expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith( + expectedGistOpts, + ); expect(state.editorMosaic.isEdited).toBe(false); }); it('asks the user for a description', async () => { - const description = 'some non-default description'; - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); - await instance.performGistAction(); - expect(mocktokit.gists.create).toHaveBeenCalledWith({ + 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(); - expect(mocktokit.gists.create).not.toHaveBeenCalled(); + await performWithDescription(instance, undefined); + expect(window.ElectronFiddle.gistCreate).not.toHaveBeenCalled(); }); describe('empty files', () => { @@ -209,12 +202,11 @@ 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 }; - expect(mocktokit.gists.create).toHaveBeenCalledWith(expected); + expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith(expected); }); it('are omitted if they are not required files', async () => { @@ -225,12 +217,11 @@ 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 }; - expect(mocktokit.gists.create).toHaveBeenCalledWith(expected); + expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith(expected); }); it('calls update() if isPublishingGistAsRevision is true', async () => { @@ -245,51 +236,43 @@ describe('Action button component', () => { }); it('handles an error in Gist publishing', async () => { - mocktokit.gists.create.mockImplementation(() => { - throw new Error(errorMessage); - }); - - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); + vi.mocked(window.ElectronFiddle.gistCreate).mockRejectedValueOnce( + new Error(errorMessage), + ); - 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(); - const { create } = mocktokit.gists; - expect(create).toHaveBeenCalledWith({ + await performWithDefaultDescription(instance); + expect(window.ElectronFiddle.gistCreate).toHaveBeenCalledWith({ ...expectedGistOpts, - public: false, + isPublic: false, }); }); it('can publish public gists', async () => { - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); instance.setPublic(); - await instance.performGistAction(); - const { create } = mocktokit.gists; - expect(create).toHaveBeenCalledWith({ + await performWithDefaultDescription(instance); + 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 }], - }, - })); - - state.showInputDialog = vi.fn().mockResolvedValueOnce(description); - await instance.performGistAction(); + vi.mocked(window.ElectronFiddle.gistCreate).mockResolvedValueOnce({ + id: 'new-gist-id', + url: 'https://gist.github.com/new-gist-id', + revision: revisionSha, + }); + + await performWithDefaultDescription(instance); expect(state.activeGistRevision).toBe(revisionSha); }); @@ -301,33 +284,31 @@ 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); - 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({ + 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 +316,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(); @@ -355,22 +336,19 @@ 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', 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..c85c77ce26 100644 --- a/tests/renderer/components/dialog-token.spec.tsx +++ b/tests/renderer/components/dialog-token.spec.tsx @@ -1,22 +1,75 @@ 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'; + 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 @@ -26,8 +79,7 @@ describe('TokenDialog component', () => { }); it('renders', () => { - store.isTokenDialogShowing = true; - render(); + renderDialog(); expect(screen.getByText('GitHub Token')).toBeInTheDocument(); expect(screen.getByText('Done')).toBeInTheDocument(); @@ -38,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, @@ -55,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, @@ -72,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({ @@ -140,266 +159,54 @@ 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(); }); 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 () => { - store.isTokenDialogShowing = true; - const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { - appState: store, - }); - act(() => { - instance.setState({ tokenInput: '' }); - }); + const { instance } = createDialog(); await instance.onSubmitToken(); expect(instance.state.verifying).toBe(false); + expect(window.ElectronFiddle.gitHubSignIn).not.toHaveBeenCalled(); }); 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(store.gitHubToken).toBe(mockValidToken); - expect(store.gitHubLogin).toBe(mockUser.login); + expect(window.ElectronFiddle.gitHubSignIn).toHaveBeenCalledWith( + mockValidToken, + ); + 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'), - ); - - 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.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 }); - }); - - await act(async () => { - await instance.onSubmitToken(); - }); - - expect(instance.state.error).toBe(true); - expect(instance.state.errorMessage).toBe( + it('surfaces the missing-gist-scope error from the main process', async () => { + await expectSubmitError( '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, - }); - 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.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/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/remote-loader.spec.ts b/tests/renderer/remote-loader.spec.ts index 274b175378..a41373eccc 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()', () => { @@ -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; diff --git a/tests/renderer/state.spec.ts b/tests/renderer/state.spec.ts index fedd88b77f..c1a19d21d5 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); }); }); diff --git a/tests/setup.ts b/tests/setup.ts index 9dd9a727d4..7ba9d2dab7 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,6 +1,7 @@ import '@testing-library/jest-dom/vitest'; import { cleanup } from '@testing-library/react'; +import { app } from 'electron'; import { configure as mobxConfigure } from 'mobx'; import { afterEach, beforeEach, expect, vi } from 'vitest'; @@ -103,6 +104,7 @@ afterEach(() => { beforeEach(() => { vi.resetAllMocks(); + (app as any)._resetMockedPaths(); (process.env.TEST as any) = true; document.body.innerHTML = '
';