diff --git a/src/components/Layout/Sidebar/Sidebar.hooks.ts b/src/components/Layout/Sidebar/Sidebar.hooks.ts index ce92ef08..e8f8dd5c 100644 --- a/src/components/Layout/Sidebar/Sidebar.hooks.ts +++ b/src/components/Layout/Sidebar/Sidebar.hooks.ts @@ -6,7 +6,7 @@ import { orderBy } from 'lodash-es' import { useEffect, useMemo } from 'react' function orderByFileName(files: Map) { - return orderBy([...files.values()], (s) => s.fileName) + return orderBy([...files.values()], (s) => s.displayName) } function toFileMap(files: StudioFile[]) { diff --git a/src/components/Layout/Sidebar/Sidebar.tsx b/src/components/Layout/Sidebar/Sidebar.tsx index a7b978f3..c9e62dfe 100644 --- a/src/components/Layout/Sidebar/Sidebar.tsx +++ b/src/components/Layout/Sidebar/Sidebar.tsx @@ -19,9 +19,10 @@ interface SidebarProps { export function Sidebar({ isExpanded, onCollapseSidebar }: SidebarProps) { const [searchTerm, setSearchTerm] = useState('') const { recordings, generators, scripts, dataFiles } = useFiles(searchTerm) - const createNewGenerator = useCreateGenerator() + const handleCreateNewGenerator = () => createNewGenerator() + const handleImportDataFile = () => { return window.studio.data.importFile() } @@ -92,7 +93,7 @@ export function Sidebar({ isExpanded, onCollapseSidebar }: SidebarProps) { aria-label="New generator" variant="ghost" size="1" - onClick={createNewGenerator} + onClick={handleCreateNewGenerator} css={{ cursor: 'pointer' }} > diff --git a/src/hooks/useCreateGenerator.test.ts b/src/hooks/useCreateGenerator.test.ts index f34181db..d697a0b8 100644 --- a/src/hooks/useCreateGenerator.test.ts +++ b/src/hooks/useCreateGenerator.test.ts @@ -1,23 +1,14 @@ import { useNavigate } from 'react-router-dom' import { useCreateGenerator } from './useCreateGenerator' -import { createNewGeneratorFile } from '@/utils/generator' -import { generateFileNameWithTimestamp } from '@/utils/file' import { getRoutePath } from '@/routeMap' import { useToast } from '@/store/ui/useToast' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderHook } from '@testing-library/react' import { act } from 'react' -import { createGeneratorData } from '@/test/factories/generator' vi.mock('react-router-dom', () => ({ useNavigate: vi.fn(), })) -vi.mock('@/utils/generator', () => ({ - createNewGeneratorFile: vi.fn(), -})) -vi.mock('@/utils/file', () => ({ - generateFileNameWithTimestamp: vi.fn(), -})) vi.mock('@/routeMap', () => ({ getRoutePath: vi.fn(), })) @@ -41,16 +32,14 @@ describe('useCreateGenerator', () => { }) it('should navigate to the correct path on successful generator creation', async () => { - const newGenerator = createGeneratorData() const fileName = 'test-file.json' const routePath = '/generator/test-file.json' - vi.mocked(createNewGeneratorFile).mockReturnValue(newGenerator) - vi.mocked(generateFileNameWithTimestamp).mockReturnValue(fileName) vi.mocked(getRoutePath).mockReturnValue(routePath) vi.stubGlobal('studio', { generator: { - saveGenerator: vi.fn().mockResolvedValue(fileName), + createGenerator: vi.fn().mockResolvedValue(fileName), + saveGenerator: vi.fn(), loadGenerator: vi.fn(), }, }) @@ -61,15 +50,7 @@ describe('useCreateGenerator', () => { await result.current() }) - expect(createNewGeneratorFile).toHaveBeenCalled() - expect(generateFileNameWithTimestamp).toHaveBeenCalledWith( - 'json', - 'Generator' - ) - expect(window.studio.generator.saveGenerator).toHaveBeenCalledWith( - JSON.stringify(newGenerator, null, 2), - fileName - ) + expect(window.studio.generator.createGenerator).toHaveBeenCalledWith('') expect(navigate).toHaveBeenCalledWith(routePath) }) diff --git a/src/hooks/useCreateGenerator.ts b/src/hooks/useCreateGenerator.ts index 4881f172..0c8340e4 100644 --- a/src/hooks/useCreateGenerator.ts +++ b/src/hooks/useCreateGenerator.ts @@ -1,8 +1,6 @@ import { useCallback } from 'react' import { useNavigate } from 'react-router-dom' -import { generateFileNameWithTimestamp } from '@/utils/file' -import { createNewGeneratorFile } from '@/utils/generator' import { getRoutePath } from '@/routeMap' import { useToast } from '@/store/ui/useToast' import log from 'electron-log/renderer' @@ -11,25 +9,25 @@ export function useCreateGenerator() { const navigate = useNavigate() const showToast = useToast() - const createTestGenerator = useCallback(async () => { - try { - const newGenerator = createNewGeneratorFile() - const fileName = await window.studio.generator.saveGenerator( - JSON.stringify(newGenerator, null, 2), - generateFileNameWithTimestamp('json', 'Generator') - ) + const createTestGenerator = useCallback( + async (recordingPath = '') => { + try { + const fileName = + await window.studio.generator.createGenerator(recordingPath) - navigate( - getRoutePath('generator', { fileName: encodeURIComponent(fileName) }) - ) - } catch (error) { - showToast({ - status: 'error', - title: 'Failed to create generator', - }) - log.error(error) - } - }, [navigate, showToast]) + navigate( + getRoutePath('generator', { fileName: encodeURIComponent(fileName) }) + ) + } catch (error) { + showToast({ + status: 'error', + title: 'Failed to create generator', + }) + log.error(error) + } + }, + [navigate, showToast] + ) return createTestGenerator } diff --git a/src/main.ts b/src/main.ts index 0debed9c..03e05049 100644 --- a/src/main.ts +++ b/src/main.ts @@ -47,9 +47,8 @@ import { INVALID_FILENAME_CHARS, TEMP_GENERATOR_SCRIPT_FILENAME, } from './constants/files' -import { generateFileNameWithTimestamp } from './utils/file' -import { HarFile } from './types/har' -import { GeneratorFile } from './types/generator' +import { HarFile, HarWithOptionalResponse } from './types/har' +import { GeneratorFileData } from './types/generator' import kill from 'tree-kill' import find from 'find-process' import { getLogContent, initializeLogger, openLogFolder } from './logger' @@ -67,9 +66,10 @@ import { import { ProxyStatus, StudioFile } from './types' import { configureApplicationMenu } from './menu' import * as Sentry from '@sentry/electron/main' -import { exhaustive } from './utils/typescript' +import { exhaustive, isNodeJsErrnoException } from './utils/typescript' import { DataFilePreview } from './types/testData' import { parseDataFile } from './utils/dataFile' +import { createNewGeneratorFile } from './utils/generator' if (process.env.NODE_ENV !== 'development') { // handle auto updates @@ -427,11 +427,19 @@ ipcMain.handle( ) // HAR -ipcMain.handle('har:save', async (_, data: string, prefix?: string) => { - const fileName = generateFileNameWithTimestamp('har', prefix) - await writeFile(path.join(RECORDINGS_PATH, fileName), data) - return fileName -}) +ipcMain.handle( + 'har:save', + async (_, data: HarWithOptionalResponse, prefix: string) => { + const fileName = await createFileWithUniqueName({ + data: JSON.stringify(data, null, 2), + directory: RECORDINGS_PATH, + ext: '.har', + prefix, + }) + + return fileName + } +) ipcMain.handle('har:open', async (_, fileName: string): Promise => { console.info('har:open event received') @@ -475,23 +483,33 @@ ipcMain.handle('har:import', async (event) => { }) // Generator +ipcMain.handle('generator:create', async (_, recordingPath: string) => { + const generator = createNewGeneratorFile(recordingPath) + const fileName = await createFileWithUniqueName({ + data: JSON.stringify(generator, null, 2), + directory: GENERATORS_PATH, + ext: '.json', + prefix: 'Generator', + }) + + return fileName +}) + ipcMain.handle( 'generator:save', - async (_, generatorFile: string, fileName: string) => { - console.info('generator:save event received') - + async (_, generator: GeneratorFileData, fileName: string) => { invariant(!INVALID_FILENAME_CHARS.test(fileName), 'Invalid file name') - await writeFile(path.join(GENERATORS_PATH, fileName), generatorFile) - return fileName + await writeFile( + path.join(GENERATORS_PATH, fileName), + JSON.stringify(generator, null, 2) + ) } ) ipcMain.handle( 'generator:open', - async (_, fileName: string): Promise => { - console.info('generator:open event received') - + async (_, fileName: string): Promise => { let fileHandle: FileHandle | undefined try { @@ -502,9 +520,7 @@ ipcMain.handle( // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const generator = await JSON.parse(data) - // TODO: https://github.com/grafana/k6-studio/issues/277 - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - return { name: fileName, content: generator } + return generator } finally { await fileHandle?.close() } @@ -589,10 +605,7 @@ ipcMain.handle( await access(newPath) throw new Error(`File with name ${newFileName} already exists`) } catch (error) { - if ( - error instanceof Error && - (error as NodeJS.ErrnoException).code !== 'ENOENT' - ) { + if (isNodeJsErrnoException(error) && error.code !== 'ENOENT') { throw error } } @@ -962,3 +975,43 @@ const cleanUpProxies = async () => { kill(proc.pid) }) } + +const createFileWithUniqueName = async ({ + directory, + data, + prefix, + ext, +}: { + directory: string + data: string + prefix: string + ext: string +}): Promise => { + const timestamp = new Date().toISOString().split('T')[0] ?? '' + const template = `${prefix ? `${prefix} - ` : ''}${timestamp}${ext}` + + // Start from 2 as it follows the the OS behavior for duplicate files + let fileVersion = 2 + let uniqueFileName = template + let fileCreated = false + + do { + try { + // ax+ flag will throw an error if the file already exists + await writeFile(path.join(directory, uniqueFileName), data, { + flag: 'ax+', + }) + fileCreated = true + } catch (error) { + if (isNodeJsErrnoException(error) && error.code !== 'EEXIST') { + throw error + } + + const { name, ext } = path.parse(template) + uniqueFileName = `${name} (${fileVersion})${ext}` + fileVersion++ + } + } while (!fileCreated) + + return uniqueFileName +} diff --git a/src/preload.ts b/src/preload.ts index d0ce9786..463efa53 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -2,8 +2,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ import { ipcRenderer, contextBridge, IpcRendererEvent } from 'electron' import { ProxyData, K6Log, K6Check, ProxyStatus, StudioFile } from './types' -import { HarFile } from './types/har' -import { GeneratorFile } from './types/generator' +import { HarFile, HarWithOptionalResponse } from './types/har' +import { GeneratorFileData } from './types/generator' import { AddToastPayload } from './types/toast' import { AppSettings } from './types/settings' import * as Sentry from './sentry' @@ -107,7 +107,10 @@ const script = { } as const const har = { - saveFile: (data: string, prefix?: string): Promise => { + saveFile: ( + data: HarWithOptionalResponse, + prefix: string + ): Promise => { return ipcRenderer.invoke('har:save', data, prefix) }, openFile: (filePath: string): Promise => { @@ -160,10 +163,16 @@ const ui = { } as const const generator = { - saveGenerator: (generatorFile: string, fileName: string): Promise => { - return ipcRenderer.invoke('generator:save', generatorFile, fileName) + createGenerator: (recordingPath: string): Promise => { + return ipcRenderer.invoke('generator:create', recordingPath) + }, + saveGenerator: ( + generator: GeneratorFileData, + fileName: string + ): Promise => { + return ipcRenderer.invoke('generator:save', generator, fileName) }, - loadGenerator: (fileName: string): Promise => { + loadGenerator: (fileName: string): Promise => { return ipcRenderer.invoke('generator:open', fileName) }, } as const diff --git a/src/types/generator.ts b/src/types/generator.ts index f42f379a..55446573 100644 --- a/src/types/generator.ts +++ b/src/types/generator.ts @@ -1,9 +1,4 @@ import { z } from 'zod' import { GeneratorFileDataSchema } from '@/schemas/generator' -export interface GeneratorFile { - name: string - content: GeneratorFileData -} - export type GeneratorFileData = z.infer diff --git a/src/utils/file.ts b/src/utils/file.ts index 2fbbc158..177aa90f 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -2,19 +2,6 @@ import { getRoutePath } from '../routeMap' import { StudioFileType } from '@/types' import { exhaustive } from './typescript' -export function generateFileNameWithTimestamp( - extension: string, - prefix?: string -) { - const timestamp = - new Date() - .toISOString() - .replace(/:/g, '-') - .replace(/T/g, '_') - .split('.')[0] ?? '' - return `${prefix ? `${prefix} - ` : ''}${timestamp}.${extension}` -} - export function getFileNameWithoutExtension(fileName: string) { return fileName.replace(/\.[^/.]+$/, '') } diff --git a/src/utils/rules.ts b/src/utils/rules.ts index 0c5b2719..c6cdbbf0 100644 --- a/src/utils/rules.ts +++ b/src/utils/rules.ts @@ -5,7 +5,7 @@ export function createEmptyRule(type: TestRule['type']): TestRule { case 'correlation': return { type: 'correlation', - id: self.crypto.randomUUID(), + id: crypto.randomUUID(), enabled: true, extractor: { filter: { path: '' }, @@ -23,7 +23,7 @@ export function createEmptyRule(type: TestRule['type']): TestRule { case 'customCode': return { type: 'customCode', - id: self.crypto.randomUUID(), + id: crypto.randomUUID(), enabled: true, filter: { path: '' }, snippet: '', @@ -32,7 +32,7 @@ export function createEmptyRule(type: TestRule['type']): TestRule { case 'parameterization': return { type: 'parameterization', - id: self.crypto.randomUUID(), + id: crypto.randomUUID(), enabled: true, filter: { path: '' }, selector: { @@ -46,7 +46,7 @@ export function createEmptyRule(type: TestRule['type']): TestRule { case 'verification': return { type: 'verification', - id: self.crypto.randomUUID(), + id: crypto.randomUUID(), enabled: true, filter: { path: '' }, selector: { diff --git a/src/utils/typescript.ts b/src/utils/typescript.ts index b1998a63..33e87299 100644 --- a/src/utils/typescript.ts +++ b/src/utils/typescript.ts @@ -15,3 +15,14 @@ export type ImmerStateCreator = StateCreator< [], T > + +export function isNodeJsErrnoException( + error: unknown +): error is NodeJS.ErrnoException { + return ( + error instanceof Error && + 'code' in error && + 'errno' in error && + 'syscall' in error + ) +} diff --git a/src/views/Generator/Generator.hooks.ts b/src/views/Generator/Generator.hooks.ts index 448f5038..d7dfe073 100644 --- a/src/views/Generator/Generator.hooks.ts +++ b/src/views/Generator/Generator.hooks.ts @@ -2,11 +2,7 @@ import { useParams } from 'react-router-dom' import invariant from 'tiny-invariant' import { useToast } from '@/store/ui/useToast' -import { - loadGeneratorFile, - loadHarFile, - writeGeneratorToFile, -} from './Generator.utils' +import { loadGeneratorFile, loadHarFile } from './Generator.utils' import { selectGeneratorData, useGeneratorStore } from '@/store/generator' import { GeneratorFileData } from '@/types/generator' import { useMutation, useQuery } from '@tanstack/react-query' @@ -41,7 +37,10 @@ export function useUpdateValueInGeneratorFile(fileName: string) { return useMutation({ mutationFn: async ({ key, value }: { key: string; value: unknown }) => { const generator = await loadGeneratorFile(fileName) - await writeGeneratorToFile(fileName, { ...generator, [key]: value }) + await window.studio.generator.saveGenerator( + { ...generator, [key]: value }, + fileName + ) }, }) } @@ -51,7 +50,7 @@ export function useSaveGeneratorFile(fileName: string) { return useMutation({ mutationFn: async (generator: GeneratorFileData) => { - await writeGeneratorToFile(fileName, generator) + await window.studio.generator.saveGenerator(generator, fileName) await queryClient.invalidateQueries({ queryKey: ['generator', fileName] }) }, diff --git a/src/views/Generator/Generator.utils.ts b/src/views/Generator/Generator.utils.ts index 04f3923c..127cc0c4 100644 --- a/src/views/Generator/Generator.utils.ts +++ b/src/views/Generator/Generator.utils.ts @@ -31,19 +31,9 @@ export async function exportScript(fileName: string) { await window.studio.script.saveScript(script, fileName) } -export const writeGeneratorToFile = ( - fileName: string, - generatorData: GeneratorFileData -) => { - return window.studio.generator.saveGenerator( - JSON.stringify(generatorData, null, 2), - fileName - ) -} - export const loadGeneratorFile = async (fileName: string) => { - const generatorFile = await window.studio.generator.loadGenerator(fileName) - return GeneratorFileDataSchema.parse(generatorFile.content) + const generator = await window.studio.generator.loadGenerator(fileName) + return GeneratorFileDataSchema.parse(generator) } export const loadHarFile = async (fileName: string) => { diff --git a/src/views/Home/Home.tsx b/src/views/Home/Home.tsx index 3a89066f..30b8b766 100644 --- a/src/views/Home/Home.tsx +++ b/src/views/Home/Home.tsx @@ -16,6 +16,8 @@ import { ExperimentalBanner } from '@/components/ExperimentalBanner' export function Home() { const createNewGenerator = useCreateGenerator() + const handleCreateNewGenerator = () => createNewGenerator() + return ( @@ -67,7 +69,7 @@ export function Home() { title="Generator" description="Transform a recorded flow into a k6 test script" > - diff --git a/src/views/Recorder/Recorder.tsx b/src/views/Recorder/Recorder.tsx index ac951e3e..0ee35d3f 100644 --- a/src/views/Recorder/Recorder.tsx +++ b/src/views/Recorder/Recorder.tsx @@ -105,11 +105,8 @@ export function Recorder() { }) const har = proxyDataToHar(grouped) - const prefix = startUrl && getHostNameFromURL(startUrl) - const fileName = await window.studio.har.saveFile( - JSON.stringify(har, null, 4), - prefix - ) + const prefix = getHostNameFromURL(startUrl) ?? 'Recording' + const fileName = await window.studio.har.saveFile(har, prefix) return fileName } finally { diff --git a/src/views/Recorder/Recorder.utils.ts b/src/views/Recorder/Recorder.utils.ts index 4a995837..40bf181f 100644 --- a/src/views/Recorder/Recorder.utils.ts +++ b/src/views/Recorder/Recorder.utils.ts @@ -75,7 +75,7 @@ function getRequestSignature(request: Request) { return `${request.method} ${request.url}` } -export function getHostNameFromURL(url: string) { +export function getHostNameFromURL(url?: string) { // ensure that a URL without protocol is parsed correctly const urlWithProtocol = url?.startsWith('http') ? url : `http://${url}` try { diff --git a/src/views/RecordingPreviewer/RecordingPreviewer.tsx b/src/views/RecordingPreviewer/RecordingPreviewer.tsx index b3ca2a8c..de8e77d8 100644 --- a/src/views/RecordingPreviewer/RecordingPreviewer.tsx +++ b/src/views/RecordingPreviewer/RecordingPreviewer.tsx @@ -5,19 +5,16 @@ import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' import { useEffect, useState } from 'react' import invariant from 'tiny-invariant' -import { - generateFileNameWithTimestamp, - getFileNameWithoutExtension, -} from '@/utils/file' +import { getFileNameWithoutExtension } from '@/utils/file' import { View } from '@/components/Layout/View' import { RequestsSection } from '@/views/Recorder/RequestsSection' -import { createNewGeneratorFile } from '@/utils/generator' import { ProxyData } from '@/types' import { harToProxyData } from '@/utils/harToProxyData' import { getRoutePath } from '@/routeMap' import { Details } from '@/components/WebLogView/Details' import { useProxyDataGroups } from '@/hooks/useProxyDataGroups' import { EmptyMessage } from '@/components/EmptyMessage' +import { useCreateGenerator } from '@/hooks/useCreateGenerator' export function RecordingPreviewer() { const [proxyData, setProxyData] = useState([]) @@ -25,6 +22,7 @@ export function RecordingPreviewer() { const [selectedRequest, setSelectedRequest] = useState(null) const { fileName } = useParams() const navigate = useNavigate() + const createTestGenerator = useCreateGenerator() // TODO: https://github.com/grafana/k6-studio/issues/277 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { state } = useLocation() @@ -52,6 +50,8 @@ export function RecordingPreviewer() { const groups = useProxyDataGroups(proxyData) + const handleCreateGenerator = () => createTestGenerator(fileName) + const handleDeleteRecording = async () => { await window.studio.ui.deleteFile({ type: 'recording', @@ -61,20 +61,6 @@ export function RecordingPreviewer() { navigate(getRoutePath('home')) } - const handleCreateTestGenerator = async () => { - const newGenerator = createNewGeneratorFile(fileName) - const generatorFileName = await window.studio.generator.saveGenerator( - JSON.stringify(newGenerator, null, 2), - generateFileNameWithTimestamp('json', 'Generator') - ) - - navigate( - getRoutePath('generator', { - fileName: encodeURIComponent(generatorFileName), - }) - ) - } - const handleDiscard = async () => { await window.studio.ui.deleteFile({ type: 'recording', @@ -103,9 +89,7 @@ export function RecordingPreviewer() { )} - +