diff --git a/src/constants.ts b/src/constants.ts index 4652758562..343829722d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,3 +2,8 @@ export const SENTRY_DSN = 'https://966a5b01ac8d4941b81e4ebd0ab4c991@sentry.io/1882540'; export const ELECTRON_DTS = 'electron.d.ts'; + +// These are the limits GitHub enforces for gist sizes. +// 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; 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..b9a3e46e16 100644 --- a/src/main/github.ts +++ b/src/main/github.ts @@ -6,6 +6,7 @@ import { IpcMainInvokeEvent, app, safeStorage } from 'electron'; import { getTemplate } from './content'; import { ipcMainManager } from './ipc'; +import { GIST_MAX_FILE_COUNT, GIST_MAX_FILE_SIZE } from '../constants'; import { EditorValues, GistLoadResult, GistRevision } from '../interfaces'; import { IpcEvents } from '../ipc-events'; import { isSupportedFile } from '../utils/editor-utils'; @@ -25,10 +26,6 @@ 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); } @@ -62,7 +59,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 +73,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; @@ -437,6 +435,7 @@ export function setupGitHub() { // Exported for testing export const testing = { fetchExample, + getCredentialsPath, handleGistCreate, handleGistDelete, handleGistListCommits, @@ -445,4 +444,6 @@ export const testing = { handleTokenCheckAuth, handleTokenSignIn, handleTokenSignOut, + loadToken, + saveToken, }; 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..4a6e67d461 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,36 @@ 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(); + expect(loadToken()).toBeNull(); + const expectedSignInResult = { success: true, login: MOCK_LOGIN }; for (const token of [VALID_GHP_TOKEN, VALID_PAT_TOKEN]) { const result = await handleTokenSignIn(MOCK_EVENT, token); expect(result).toEqual({ success: true, login: MOCK_LOGIN }); - 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(loadToken()).toBe(token); + expect(fs.readFileSync(getCredentialsPath())).toEqual(encrypted); + // POSIX permission bits aren't meaningful on Windows + if (process.platform !== 'win32') + expect(fs.statSync(getCredentialsPath()).mode & 0o777).toBe(0o600); } - - expect(fs.writeFileSync).toHaveBeenCalled(); }); it('rejects an invalid token format', async () => { @@ -207,61 +208,45 @@ 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(); - }); - - it('does nothing when the token file does not exist', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + // setup: set a token & confirm it loads + saveToken(VALID_GHP_TOKEN); + expect(loadToken()).toBe(VALID_GHP_TOKEN); - await handleTokenSignOut(MOCK_EVENT); - - expect(fs.unlinkSync).not.toHaveBeenCalled(); + const expected = { success: true }; + await expect(handleTokenSignOut(MOCK_EVENT)).resolves.toEqual(expected); + 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); + // setup: confirm there's no token + expect(loadToken()).toBeNull(); + 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 +259,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 +273,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); }); }); @@ -299,9 +281,6 @@ describe('github', () => { async function signInForGistTests() { mockOctokitInstance(); 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 +333,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' }]; }), @@ -702,16 +681,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 +705,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 +718,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 +727,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 +831,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 +844,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.ts b/tests/mocks/electron.ts index 6d87a0a0b9..9cf679fa63 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,15 @@ 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((key: string) => { + const ret = mockedPaths[key]; + if (!ret) throw new Error(`Unexpected call: app.getPath(${key})`); + return ret; + }), + 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/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 = '
';