diff --git a/src/ambient.d.ts b/src/ambient.d.ts index 3dca546b18..fd090efbf3 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, @@ -110,8 +115,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 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/interfaces.ts b/src/interfaces.ts index 967fde54bf..35324ab799 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -219,17 +219,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/github.ts b/src/main/github.ts index b9a3e46e16..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 } from '../constants'; -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'; @@ -17,9 +29,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 +36,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 { @@ -46,11 +55,6 @@ function isValidDescription(description: unknown): description is string { ); } -interface GistFile { - filename: string; - content: string; -} - function areValidGistFiles( files: unknown, ): files is Record { @@ -123,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.' }; @@ -161,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.', @@ -168,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 }; @@ -201,12 +193,6 @@ async function handleTokenCheckAuth( } } -interface GistWriteResult { - id: string; - url: string; - revision?: string; -} - async function handleGistCreate( _event: IpcMainInvokeEvent, params: unknown, @@ -254,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 { @@ -274,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( @@ -301,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. @@ -314,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, }; } diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 1108986bf9..345198439c 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, @@ -109,10 +111,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 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/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..2f1f44ff5c 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(); @@ -214,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(); }); }); @@ -451,8 +448,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(); } }); @@ -478,7 +476,6 @@ describe('github', () => { gistId: VALID_GIST_ID, }); - expect(result.id).toBe(VALID_GIST_ID); expect(result.files['main.js'].content).toBe('console.log("hi")'); }); @@ -488,7 +485,7 @@ describe('github', () => { revision: VALID_SHA, }); - expect(result.id).toBe(VALID_GIST_ID); + expect(result.revision).toBe('sha1'); }); it('rejects invalid gist IDs', async () => { @@ -515,7 +512,7 @@ describe('github', () => { revision, }); - expect(result.id).toBe(VALID_GIST_ID); + expect(result.revision).toBe('sha1'); } }); @@ -544,11 +541,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); diff --git a/tests/mocks/electron-fiddle.ts b/tests/mocks/electron-fiddle.ts index 17059156cb..0d3bb43348 100644 --- a/tests/mocks/electron-fiddle.ts +++ b/tests/mocks/electron-fiddle.ts @@ -11,8 +11,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/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..7338f9433b 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'; @@ -11,52 +10,21 @@ import { 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; + isPublic: boolean; }; describe('Action button component', () => { const description = 'Electron Fiddle Gist'; const errorMessage = '💀'; let app: App; - let mocktokit: OctokitMock; let state: AppState; let expectedGistOpts: GistCreateOpts; @@ -73,19 +41,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', @@ -146,7 +119,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 +127,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(); @@ -175,7 +148,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 +159,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 +169,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 +178,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 +191,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 +207,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 +222,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 +238,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 +248,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 +279,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 () => { 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 +309,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(); @@ -364,13 +338,13 @@ describe('Action button component', () => { it('attempts to delete an existing Gist', 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..4e23a208aa 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'; @@ -76,7 +72,6 @@ describe('TokenDialog component', () => { const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { appState: store, }); - act(() => { instance.setState({ verifying: true, @@ -101,7 +96,6 @@ describe('TokenDialog component', () => { const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { appState: store, }); - act(() => { instance.setState({ verifying: true, @@ -144,7 +138,6 @@ describe('TokenDialog component', () => { const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { appState: store, }); - act(() => { instance.setState({ verifying: true, tokenInput: 'hello' }); }); @@ -154,27 +147,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 () => { @@ -182,13 +161,11 @@ describe('TokenDialog component', () => { const { instance } = renderClassComponentWithInstanceRef(TokenDialog, { appState: store, }); - act(() => { - instance.setState({ tokenInput: '' }); - }); await instance.onSubmitToken(); expect(instance.state.verifying).toBe(false); + expect(window.ElectronFiddle.gitHubSignIn).not.toHaveBeenCalled(); }); it('tries to sign the user in', async () => { @@ -200,18 +177,21 @@ describe('TokenDialog component', () => { instance.setState({ tokenInput: mockValidToken }); }); - await instance.onSubmitToken(); + await act(() => instance.onSubmitToken()); - 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'), - ); + 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, { @@ -229,44 +209,16 @@ 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).toBeNull(); }); - 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(); + 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.', }); - 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, @@ -283,123 +235,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); - }); - }); - - 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, - }); + expect(store.gitHubLogin).toBeNull(); }); }); }); 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 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); }); });