diff --git a/examples/collection/echo/pre-request-script.js b/examples/collection/echo/pre-request-script.js new file mode 100644 index 00000000..6e0e192d --- /dev/null +++ b/examples/collection/echo/pre-request-script.js @@ -0,0 +1,3 @@ +const key = 'counter'; +const value = parseInt(trufos.getCollectionVariable(key) || '0'); +trufos.setCollectionVariable(key, (value + 1).toString()); diff --git a/examples/collection/echo/request-body.txt b/examples/collection/echo/request-body.txt index c502b641..3ec0a93e 100644 --- a/examples/collection/echo/request-body.txt +++ b/examples/collection/echo/request-body.txt @@ -1,3 +1,4 @@ { - "randomUuid": "{{$randomUuid}}" + "randomUuid": "{{$randomUuid}}", + "counter": {{counter}} } \ No newline at end of file diff --git a/src/main/event/main-event-service.test.ts b/src/main/event/main-event-service.test.ts index d6bb9bf0..0308e1f6 100644 --- a/src/main/event/main-event-service.test.ts +++ b/src/main/event/main-event-service.test.ts @@ -19,6 +19,13 @@ vi.mock('electron', () => ({ })); vi.mock('./stream-events', () => ({})); +vi.mock('main/network/service/http-service', () => ({ + HttpService: { + instance: { + fetchAsync: vi.fn(), + }, + }, +})); const TEST_STRING = 'Hello, World!'; const TEST_FILE_PATH = path.join(tmpdir(), 'test.txt'); diff --git a/src/main/event/main-event-service.ts b/src/main/event/main-event-service.ts index 24f36054..2a890ce4 100644 --- a/src/main/event/main-event-service.ts +++ b/src/main/event/main-event-service.ts @@ -1,7 +1,6 @@ import { app, dialog, ipcMain } from 'electron'; import { EnvironmentService } from 'main/environment/service/environment-service'; import { HttpService } from 'main/network/service/http-service'; -import type { IEventService, ImportStrategy } from 'shim/event-service'; import type { Collection, TrufosObject, @@ -9,12 +8,17 @@ import type { TrufosRequest, VariableMap, EnvironmentMap, -} from 'shim/objects'; + ScriptType, + IEventService, + ImportStrategy, +} from 'shim'; import { PersistenceService } from '../persistence/service/persistence-service'; -import './stream-events'; import { ImportService } from 'main/import/service/import-service'; import { updateElectronApp } from 'update-electron-app'; +// register stream events +import './stream-events'; + const persistenceService = PersistenceService.instance; const environmentService = EnvironmentService.instance; const importService = ImportService.instance; @@ -95,6 +99,10 @@ export class MainEventService implements IEventService { return await persistenceService.saveRequest(request, textBody); } + async saveScript(request: TrufosRequest, type: ScriptType, script: string) { + return await persistenceService.saveScript(request, type, script); + } + async copyRequest(request: TrufosRequest): Promise { return await persistenceService.copyRequest(request); } diff --git a/src/main/event/stream-events.ts b/src/main/event/stream-events.ts index f6a838be..326c00ca 100644 --- a/src/main/event/stream-events.ts +++ b/src/main/event/stream-events.ts @@ -34,7 +34,15 @@ ipcMain.handle( return id; } stream = createReadStream(filePath, encoding); - } else if ((stream = await persistenceService.loadTextBodyOfRequest(input, encoding)) == null) { + } else if (input.type === 'script') { + stream = await persistenceService.loadScript(input.request, input.source); + } else if (input.type === 'request') { + stream = await persistenceService.loadTextBodyOfRequest(input, encoding); + } else { + logger.error('Invalid stream input:', input); + } + + if (stream == null) { setImmediate(() => sender.send('stream-end', id)); return id; } diff --git a/src/main/network/service/http-service.test.ts b/src/main/network/service/http-service.test.ts index 4b0f43b0..48477eed 100644 --- a/src/main/network/service/http-service.test.ts +++ b/src/main/network/service/http-service.test.ts @@ -2,15 +2,18 @@ import { HttpService } from './http-service'; import { MockAgent } from 'undici'; import fs from 'node:fs'; import { TrufosRequest } from 'shim/objects/request'; -import { parseUrl, TrufosURL } from 'shim/objects/url'; +import { parseUrl } from 'shim/objects/url'; import { randomUUID } from 'node:crypto'; import { RequestMethod } from 'shim/objects/request-method'; import { IncomingHttpHeaders } from 'undici/types/header'; -import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; import { AuthorizationType } from 'shim/objects'; import { EnvironmentService } from 'main/environment/service/environment-service'; import { TemplateReplaceStream } from 'template-replace-stream'; import { ResponseBodyService } from 'main/network/service/response-body-service'; +import { PersistenceService } from 'main/persistence/service/persistence-service'; +import { ScriptingService } from 'main/scripting/scripting-service'; +import { Readable } from 'node:stream'; const mockAgent = new MockAgent({ connections: 1 }); const environmentService = EnvironmentService.instance; @@ -22,6 +25,10 @@ describe('HttpService', () => { mockAgent.enableCallHistory(); }); + beforeEach(() => { + vi.spyOn(PersistenceService.instance, 'loadScript').mockResolvedValue(null); + }); + it('fetchAsync() should make an HTTP call and return the body on read', async () => { // Arrange const text = 'Hello, world!'; @@ -264,6 +271,37 @@ describe('HttpService', () => { expect(spy).toHaveBeenCalled(); spy.mockRestore(); }); + + it('fetchAsync() should execute scripts when provided', async () => { + // Arrange + const preScript = 'console.log("pre");'; + const postScript = 'console.log("post");'; + vi.spyOn(PersistenceService.instance, 'loadScript') + .mockResolvedValueOnce(Readable.from(preScript) as fs.ReadStream) + .mockResolvedValueOnce(Readable.from(postScript) as fs.ReadStream); + const executeSpy = vi.spyOn(ScriptingService.instance, 'executeScript'); + + const url = new URL('https://example.com/api/data'); + const httpService = setupMockHttpService(url, 'OK'); + const request: TrufosRequest = { + id: randomUUID(), + parentId: randomUUID(), + type: 'request', + title: 'Scripted Request', + url: parseUrl(url.toString()), + method: RequestMethod.GET, + headers: [], + body: null, + }; + + // Act + await httpService.fetchAsync(request); + + // Assert + expect(executeSpy).toHaveBeenCalledTimes(2); + expect(executeSpy.mock.calls[0]?.[0]).toEqual(preScript); + expect(executeSpy.mock.calls[1]?.[0]).toEqual(postScript); + }); }); function setupMockHttpService( diff --git a/src/main/network/service/http-service.ts b/src/main/network/service/http-service.ts index 36757073..4dae142f 100644 --- a/src/main/network/service/http-service.ts +++ b/src/main/network/service/http-service.ts @@ -14,11 +14,15 @@ import { calculateResponseSize } from 'main/util/size-calculation'; import { app } from 'electron'; import process from 'node:process'; import { ResponseBodyService } from 'main/network/service/response-body-service'; +import { ScriptingService } from 'main/scripting/scripting-service'; +import { ScriptType } from 'shim/scripting'; +import { text } from 'node:stream/consumers'; const fileSystemService = FileSystemService.instance; const environmentService = EnvironmentService.instance; const persistenceService = PersistenceService.instance; const responseBodyService = ResponseBodyService.instance; +const scriptingService = ScriptingService.instance; declare type HttpHeaders = Record; @@ -65,6 +69,9 @@ export class HttpService { this.trufosHeadersToUndiciHeaders(request.headers) ); + // execute pre-request script if it exists + await this.executeScript(request, ScriptType.PRE_REQUEST); + const { stream, size } = await this.readBody(request); // measure duration of the request @@ -83,7 +90,10 @@ export class HttpService { }); const duration = getDurationFromNow(now); - logger.info(`Received response in ${duration} milliseconds`); + logger.info(`Received response in ${duration}ms`); + + // execute post-response script if it exists + await this.executeScript(request, ScriptType.POST_RESPONSE); // write the response body to a temporary file const bodyFile = fileSystemService.temporaryFile(); @@ -182,4 +192,12 @@ export class HttpService { private resolveVariablesInHeaderValues(values: string[]) { return Promise.all(values.map((value) => environmentService.setVariablesInString(value))); } + + private async executeScript(request: TrufosRequest, type: ScriptType) { + const stream = await persistenceService.loadScript(request, type); + if (stream != null) { + const script = await text(stream); + scriptingService.executeScript(script); + } + } } diff --git a/src/main/persistence/service/persistence-service.ts b/src/main/persistence/service/persistence-service.ts index 829fbb0d..f68760a3 100644 --- a/src/main/persistence/service/persistence-service.ts +++ b/src/main/persistence/service/persistence-service.ts @@ -37,6 +37,7 @@ import { OrderFile, SECRETS_FILE_NAME, } from 'main/persistence/constants'; +import { ScriptType } from 'shim'; const secretService = SecretService.instance; @@ -305,6 +306,17 @@ export class PersistenceService { return folderCopy; } + public async saveScript(request: TrufosRequest, type: ScriptType, script: string) { + await fs.writeFile(this.getScriptFilePath(request, type), script); + } + + public async loadScript(request: TrufosRequest, type: ScriptType) { + const filePath = this.getScriptFilePath(request, type); + if (await exists(filePath)) { + return createReadStream(filePath, 'utf-8'); + } + } + /** * Saves the information of a trufos object to the file system. * @param object the object to save @@ -666,6 +678,21 @@ export class PersistenceService { return `${type}.json`; } + private getScriptFilePath(request: TrufosRequest, type: ScriptType) { + let dirPath = this.getOrCreateDirPath(request); + if (request.draft) dirPath = this.getDraftDirPath(dirPath); + return path.join(dirPath, this.getScriptFileName(type)); + } + + /** + * Gets the script file name for a given script type. + * @param type the type of the script + * @returns the script file name with file extension `.js` + */ + private getScriptFileName(type: ScriptType) { + return `${type}-script.js`; + } + private isDirPathTaken(targetDirPath: string) { return this.idToPathMap.values().some((path) => path === targetDirPath); } diff --git a/src/main/scripting/scripting-service.test.ts b/src/main/scripting/scripting-service.test.ts new file mode 100644 index 00000000..cdcc16e3 --- /dev/null +++ b/src/main/scripting/scripting-service.test.ts @@ -0,0 +1,424 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { app } from 'electron'; +import type { VariableMap } from 'shim/objects/variables'; +import { ScriptingService } from './scripting-service'; +import { vol } from 'memfs'; + +const mockState = vi.hoisted(() => { + const collection: { + id: string; + title: string; + dirPath: string; + type: 'collection'; + variables: VariableMap; + environments: Record; + children: unknown[]; + } = { + id: 'test-collection', + title: 'Test Collection', + dirPath: '/test-collection', + type: 'collection', + variables: { + testVar: { value: 'testValue' }, + count: { value: '42' }, + } satisfies VariableMap, + environments: { + dev: { + variables: { + baseUrl: { value: 'http://localhost:3000' }, + } satisfies VariableMap, + }, + }, + children: [], + }; + + const environmentService = { + currentEnvironmentKey: 'dev' as string | undefined, + get currentCollection() { + return collection; + }, + get currentEnvironment() { + return this.currentEnvironmentKey + ? collection.environments[this.currentEnvironmentKey] + : undefined; + }, + }; + + return { collection, environmentService }; +}); + +vi.mock('main/environment/service/environment-service', () => ({ + EnvironmentService: { + instance: mockState.environmentService, + }, +})); + +describe('ScriptingService', () => { + let service: ScriptingService; + + beforeEach(() => { + vi.clearAllMocks(); + vol.reset(); + + mockState.collection.variables = { + testVar: { value: 'testValue' }, + count: { value: '42' }, + } satisfies VariableMap; + mockState.collection.environments = { + dev: { + variables: { + baseUrl: { value: 'http://localhost:3000' }, + } satisfies VariableMap, + }, + }; + mockState.environmentService.currentEnvironmentKey = 'dev'; + + vi.spyOn(app, 'getVersion').mockReturnValue('1.0.0'); + + // Reset singleton instance for fresh context + ScriptingService._instance = null; + service = ScriptingService.instance; + }); + + describe('executeScript() - Basic Execution', () => { + it('should execute a simple script successfully', () => { + // Arrange + const code = 'const x = 1 + 1; console.log(x);'; + + // Act + service.executeScript(code); + + // Assert + expect(() => service.executeScript(code)).not.toThrow(); + }); + + it('should provide access to trufos.version in script context', () => { + // Arrange + const code = ` + if (trufos.version !== '1.0.0') { + throw new Error('Version mismatch: ' + trufos.version); + } + `; + + // Act & Assert + expect(() => service.executeScript(code)).not.toThrow(); + }); + + it('should execute script with multiple statements', () => { + // Arrange + const code = ` + let result = 0; + for (let i = 0; i < 5; i++) { + result += i; + } + if (result !== 10) { + throw new Error('Expected 10, got ' + result); + } + `; + + // Act & Assert + expect(() => service.executeScript(code)).not.toThrow(); + }); + + it('should prevent modification of frozen trufos API object', () => { + // Arrange + const code = ` + const original = trufos.version; + try { + trufos.version = '2.0.0'; + } catch (e) { + // Expected in strict mode + } + if (trufos.version !== original) { + throw new Error('trufos.version should remain read-only'); + } + `; + + // Act & Assert + expect(() => service.executeScript(code)).not.toThrow(); + }); + }); + + describe('executeScript() - Variable Access and Mutation', () => { + it('should read collection variables using getCollectionVariable()', () => { + // Arrange + const code = ` + if (trufos.getCollectionVariable('testVar') !== 'testValue') { + throw new Error('Failed to read testVar'); + } + if (trufos.getCollectionVariable('count') !== '42') { + throw new Error('Failed to read count'); + } + `; + + // Act & Assert + expect(() => service.executeScript(code)).not.toThrow(); + }); + + it('should return undefined for non-existent collection variables', () => { + // Arrange + const code = ` + if (trufos.getCollectionVariable('nonExistent') !== undefined) { + throw new Error('Non-existent variable should return undefined'); + } + `; + + // Act & Assert + expect(() => service.executeScript(code)).not.toThrow(); + }); + + it('should allow script to modify collection variables with string value', () => { + // Arrange + const code = ` + trufos.setCollectionVariable('testVar', 'modified'); + trufos.setCollectionVariable('count', '100'); + trufos.setCollectionVariable('newVar', 'newValue'); + `; + + // Act + service.executeScript(code); + + // Assert + expect(mockState.collection.variables.testVar.value).toBe('modified'); + expect(mockState.collection.variables.count.value).toBe('100'); + expect(mockState.collection.variables.newVar.value).toBe('newValue'); + }); + + it('should allow script to modify collection variables with VariableObject', () => { + // Arrange + const code = ` + trufos.setCollectionVariable('testVar', { value: 'modified', description: 'Updated' }); + trufos.setCollectionVariable('newVar', { value: 'newValue', description: 'New variable' }); + `; + + // Act + service.executeScript(code); + + // Assert + expect(mockState.collection.variables.testVar.value).toBe('modified'); + expect(mockState.collection.variables.testVar.description).toBe('Updated'); + expect(mockState.collection.variables.newVar.value).toBe('newValue'); + expect(mockState.collection.variables.newVar.description).toBe('New variable'); + }); + + it('should provide access to current environment variables', () => { + // Arrange + const code = ` + if (trufos.getEnvironmentVariable(undefined, 'baseUrl') !== 'http://localhost:3000') { + throw new Error('Failed to read environment baseUrl'); + } + `; + + // Act & Assert + expect(() => service.executeScript(code)).not.toThrow(); + }); + + it('should provide access to named environment variables', () => { + // Arrange + const code = ` + if (trufos.getEnvironmentVariable('dev', 'baseUrl') !== 'http://localhost:3000') { + throw new Error('Failed to read named environment baseUrl'); + } + `; + + // Act & Assert + expect(() => service.executeScript(code)).not.toThrow(); + }); + + it('should return undefined for non-existent environment variables', () => { + // Arrange + const code = ` + if (trufos.getEnvironmentVariable(undefined, 'nonExistent') !== undefined) { + throw new Error('Non-existent environment variable should return undefined'); + } + `; + + // Act & Assert + expect(() => service.executeScript(code)).not.toThrow(); + }); + + it('should allow script to set current environment variables with string value', () => { + // Arrange + const code = ` + trufos.setEnvironmentVariable('baseUrl', 'http://localhost:4000'); + trufos.setEnvironmentVariable('apiKey', 'secret123'); + `; + + // Act + service.executeScript(code); + + // Assert + expect(mockState.collection.environments.dev.variables.baseUrl.value).toBe( + 'http://localhost:4000' + ); + expect(mockState.collection.environments.dev.variables.apiKey.value).toBe('secret123'); + }); + + it('should allow script to set named environment variables with VariableObject', () => { + // Arrange + const code = ` + trufos.setEnvironmentVariable('baseUrl', { value: 'http://localhost:5000', description: 'Updated URL' }, 'dev'); + trufos.setEnvironmentVariable('newVar', { value: 'test', description: 'New env var' }, 'dev'); + `; + + // Act + service.executeScript(code); + + // Assert + expect(mockState.collection.environments.dev.variables.baseUrl.value).toBe( + 'http://localhost:5000' + ); + expect(mockState.collection.environments.dev.variables.baseUrl.description).toBe( + 'Updated URL' + ); + expect(mockState.collection.environments.dev.variables.newVar.value).toBe('test'); + expect(mockState.collection.environments.dev.variables.newVar.description).toBe( + 'New env var' + ); + }); + }); + + describe('executeScriptFromFile()', () => { + it('should read and execute script from file', async () => { + // Arrange + + const filePath = '/test-script.js'; + const code = `const result = 1 + 1;`; + + vol.writeFileSync(filePath, code); + + // Act + await service.executeScriptFromFile(filePath); + + // Assert + await expect(service.executeScriptFromFile(filePath)).resolves.toBeUndefined(); + }); + + it('should execute script from file with valid code', async () => { + // Arrange + + const filePath = '/valid-script.js'; + const code = ` + if (trufos.version !== '1.0.0') { + throw new Error('Version check failed'); + } + `; + + vol.writeFileSync(filePath, code); + + // Act & Assert + await expect(service.executeScriptFromFile(filePath)).resolves.toBeUndefined(); + }); + + it('should execute multiline script from file', async () => { + // Arrange + + const filePath = '/multiline-script.js'; + const code = ` + let result = 0; + for (let i = 0; i < 10; i++) { + result += i; + } + if (result !== 45) { + throw new Error('Expected 45, got ' + result); + } + `; + + vol.writeFileSync(filePath, code); + + // Act & Assert + await expect(service.executeScriptFromFile(filePath)).resolves.toBeUndefined(); + }); + }); + + describe('ScriptingService Singleton', () => { + it('should be a singleton instance', () => { + // Arrange & Act + const instance1 = ScriptingService.instance; + const instance2 = ScriptingService.instance; + + // Assert + expect(instance1).toBe(instance2); + }); + + it('should NOT persist variables across script executions', () => { + // Arrange + + // Act - First script: define a global variable + service.executeScript('globalVar = 42;'); + + // Second script: try to access the variable from the first script + const accessCode = ` + if (typeof globalVar !== 'undefined') { + throw new Error('Variables should NOT persist across script executions'); + } + `; + + // Assert - Each execution gets a fresh context + expect(() => service.executeScript(accessCode)).not.toThrow(); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty script string', () => { + // Arrange + + // Act & Assert + expect(() => service.executeScript('')).not.toThrow(); + }); + + it('should handle script with only comments', () => { + // Arrange + + const code = ` + // This is a comment + /* This is a block comment */ + `; + + // Act & Assert + expect(() => service.executeScript(code)).not.toThrow(); + }); + + it('should handle script with console output', () => { + // Arrange + + const code = `console.log('Script output');`; + + // Act & Assert + expect(() => service.executeScript(code)).not.toThrow(); + }); + + it('should allow same const variable names in different script executions', () => { + // Arrange + + // Act - First script: declare a const variable + service.executeScript('const key = "counter";'); + + // Second script: declare same const variable - should NOT throw "already declared" error + const secondCode = `const key = "counter";`; + + // Assert - Should not throw because each execution gets a fresh context + expect(() => service.executeScript(secondCode)).not.toThrow(); + }); + + it('should provide fresh trufos API in each script execution', () => { + // Arrange + + // Act - First script: use trufos API + service.executeScript(`trufos.setCollectionVariable('counter', '1');`); + + // Verify the collection variable was set + expect(mockState.collection.variables.counter.value).toBe('1'); + + // Second script: use trufos API - should work because trufos is always available + const secondCode = ` + const currentValue = trufos.getCollectionVariable('counter'); + trufos.setCollectionVariable('counter', String(parseInt(currentValue) + 1)); + `; + + // Assert + expect(() => service.executeScript(secondCode)).not.toThrow(); + expect(mockState.collection.variables.counter.value).toBe('2'); + }); + }); +}); diff --git a/src/main/scripting/scripting-service.ts b/src/main/scripting/scripting-service.ts new file mode 100644 index 00000000..50e314e0 --- /dev/null +++ b/src/main/scripting/scripting-service.ts @@ -0,0 +1,103 @@ +import { app } from 'electron'; +import { EnvironmentService } from 'main/environment/service/environment-service'; +import { createContext, Script } from 'node:vm'; +import { GlobalScriptingApi, VariableMap, VariableObject } from 'shim'; +import fs from 'node:fs/promises'; +import { getDurationStringFromNow, getSteadyTimestamp } from 'main/util/time-util'; + +const environmentService = EnvironmentService.instance; + +const SCRIPT_TIMEOUT_SECONDS = 5; + +/** + * Service for executing user-provided scripts in an isolated VM context. + * Scripts run in a Node.js vm context with access to the scripting API. + */ +export class ScriptingService { + public static _instance: ScriptingService | null = null; + + private get api() { + return Object.freeze({ + trufos: { + version: app.getVersion(), + + getCollectionVariable(name) { + return ScriptingService.getVariable(environmentService.currentCollection.variables, name); + }, + + setCollectionVariable(name, value) { + ScriptingService.setVariable(environmentService.currentCollection.variables, name, value); + }, + + getEnvironmentVariable(name, environment) { + const variables = ScriptingService.getEnvironmentVariables(environment); + return ScriptingService.getVariable(variables, name); + }, + + setEnvironmentVariable(name, value, environment) { + const variables = ScriptingService.getEnvironmentVariables(environment); + ScriptingService.setVariable(variables, name, value); + }, + }, + }); + } + + public static get instance() { + if (!ScriptingService._instance) { + ScriptingService._instance = new ScriptingService(); + } + return ScriptingService._instance; + } + + private static getVariable(map: VariableMap, name: string) { + return map[name]?.value; + } + + private static setVariable(map: VariableMap, name: string, value: string | VariableObject) { + if (typeof value === 'string') value = { value }; + VariableObject.parse(value); // validate type and required properties + map[name] = { ...(map[name] ?? {}), ...value }; + } + + private static getEnvironmentVariables(name?: string) { + const environment = + name == null + ? environmentService.currentEnvironment + : environmentService.currentCollection.environments[name]; + return environment?.variables ?? {}; + } + + public async executeScriptFromFile(filePath: string) { + logger.info(`Executing script from file: ${filePath}`); + let code: string; + try { + code = await fs.readFile(filePath, 'utf-8'); + } catch (err) { + logger.error('Failed to read script file:', err); + return; + } + this.executeScript(code); + } + + public executeScript(code: string) { + let script: Script; + try { + const now = getSteadyTimestamp(); + script = new Script(code); + logger.debug(`Script compiled successfully after ${getDurationStringFromNow(now)}`); + } catch (err) { + // TODO: Show error to user instead of just logging it. + logger.info('Script compilation error', err); + return; + } + try { + const context = createContext(this.api, { name: 'Trufos Scripting Context' }); + const now = getSteadyTimestamp(); + script.runInContext(context, { timeout: SCRIPT_TIMEOUT_SECONDS * 1000 }); + logger.debug(`Script executed successfully after ${getDurationStringFromNow(now)}`); + } catch (err) { + // TODO: Show error to user instead of just logging it. + logger.info('Script execution error', err); + } + } +} diff --git a/src/main/util/time-util.ts b/src/main/util/time-util.ts index 020e44c4..28adaa1b 100644 --- a/src/main/util/time-util.ts +++ b/src/main/util/time-util.ts @@ -1,10 +1,22 @@ +import { performance } from 'node:perf_hooks'; + declare type TimeUnit = 'ns' | 'us' | 'ms' | 's'; /** * Returns a timestamp that can be used to measure time intervals. */ export function getSteadyTimestamp() { - return process.hrtime.bigint(); + return performance.now(); +} + +/** + * Returns a human-readable string representing the duration between the given timestamp and now. + * @param timestamp the timestamp to compare to + * @param unit the unit to return the difference in. Default is "ms". + * @param precision number of decimal places to include in the output. Default is 2. + */ +export function getDurationStringFromNow(timestamp: number, unit: TimeUnit = 'ms', precision = 2) { + return `${getDurationFromNow(timestamp, unit).toFixed(precision)}${unit}`; } /** @@ -12,18 +24,18 @@ export function getSteadyTimestamp() { * @param timestamp the timestamp to compare to * @param unit the unit to return the difference in. Default is "ms". */ -export function getDurationFromNow(timestamp: bigint, unit = 'ms' as TimeUnit) { - const diff = Number(getSteadyTimestamp() - timestamp); +export function getDurationFromNow(timestamp: number, unit = 'ms' as TimeUnit) { + const diff = performance.now() - timestamp; switch (unit) { case 'ns': - return diff; + return diff * 1e6; case 'us': - return diff / 1e3; + return diff * 1e3; case 'ms': - return diff / 1e6; + return diff; case 's': - return diff / 1e9; + return diff / 1e3; default: throw new Error(`Unknown unit: ${unit}`); } diff --git a/src/renderer/services/event/renderer-event-service.ts b/src/renderer/services/event/renderer-event-service.ts index c2111f2d..3a80489c 100644 --- a/src/renderer/services/event/renderer-event-service.ts +++ b/src/renderer/services/event/renderer-event-service.ts @@ -68,4 +68,5 @@ export class RendererEventService implements IEventService { // @ts-expect-error - typing does not work for genric methods reorderItem = createEventMethod('reorderItem'); updateApp = createEventMethod('updateApp'); + saveScript = createEventMethod('saveScript'); } diff --git a/src/shim/event-service.ts b/src/shim/event-service.ts index a59348ad..e9e9cfe4 100644 --- a/src/shim/event-service.ts +++ b/src/shim/event-service.ts @@ -9,6 +9,7 @@ import { VariableObject, EnvironmentMap, } from './objects'; +import { ScriptType } from './scripting'; export type ImportStrategy = 'Postman' | 'Bruno' | 'Insomnia'; @@ -199,4 +200,12 @@ export interface IEventService { * Will show a dialog to the user if an update is available. */ updateApp(): void; + + /** + * Saves the script for the given request and script type to the file system. + * @param request The request the script belongs to. + * @param type The type of the script (e.g. pre-request) + * @param script The script content to save. + */ + saveScript(request: TrufosRequest, type: ScriptType, script: string): Promise; } diff --git a/src/shim/index.ts b/src/shim/index.ts index 751b8ecf..ecd04abc 100644 --- a/src/shim/index.ts +++ b/src/shim/index.ts @@ -3,3 +3,4 @@ export * from './headers'; export * from './fs'; export * from './ipc-stream'; export * from './event-service'; +export * from './scripting'; diff --git a/src/shim/ipc-stream.ts b/src/shim/ipc-stream.ts index 630f02cd..b091b844 100644 --- a/src/shim/ipc-stream.ts +++ b/src/shim/ipc-stream.ts @@ -1,6 +1,14 @@ import { TrufosRequest } from 'shim/objects/request'; import { TrufosResponse } from 'shim/objects/response'; +import { ScriptType } from './scripting'; -export type StreamInput = string | TrufosRequest | TrufosResponse; +/** + * The input for opening a stream. If a string is passed, it is treated as a file path. + */ +export type StreamInput = + | string + | TrufosRequest + | TrufosResponse + | { type: 'script'; source: ScriptType; request: TrufosRequest }; export type StringBufferEncoding = Exclude; diff --git a/src/shim/scripting.ts b/src/shim/scripting.ts new file mode 100644 index 00000000..6151dd4f --- /dev/null +++ b/src/shim/scripting.ts @@ -0,0 +1,70 @@ +import { VariableObject } from './objects'; + +export enum ScriptType { + /** Executed before the request is sent. */ + PRE_REQUEST = 'pre-request', + + /** Executed after the response is received. */ + POST_RESPONSE = 'post-response', +} + +export interface GlobalScriptingApi { + readonly trufos: { + /** The current version of the app. */ + readonly version: string; + + /** + * Get a variable from the current collection. + * @param name The name of the variable. + * @returns The current value of the variable, or `undefined` if it doesn't exist. + */ + getCollectionVariable(name: string): string | undefined; + + /** + * Set a variable in the current collection. If the variable doesn't exist, it will be created. + * @param name The name of the variable. + * @param value The value to set for the variable. Can be a string or an object with additional properties. + * @example + * // Set a simple string variable + * trufos.setCollectionVariable('apiUrl', 'https://api.example.com'); + * + * // Set a variable with additional properties + * trufos.setCollectionVariable('userToken', { + * value: 'abc123', + * description: 'Token for authenticating API requests', + * secret: true, // does not appear in git and is stored encrypted + * }); + */ + setCollectionVariable(name: string, value: string | VariableObject): void; + + /** + * Get a variable from an environment. + * @param name The name of the variable. + * @param environment The name of the environment to get the variable from, or `undefined` to get it from the currently selected environment. + * @returns The current value of the variable, or `undefined` if it doesn't exist. + */ + getEnvironmentVariable(name: string, environment?: string): string | undefined; + + /** + * Set a variable in an environment. If the variable doesn't exist, it will be created. + * @param name The name of the variable. + * @param value The value to set for the variable. Can be a string or an object with additional properties. + * @param environment The name of the environment to set the variable in, or `undefined` to set it in the currently selected environment. + * @example + * // Set a simple string variable in the current environment + * trufos.setEnvironmentVariable(undefined, 'apiUrl', 'https://api.example.com'); + * + * // Set a variable with additional properties in a specific environment + * trufos.setEnvironmentVariable('staging', 'userToken', { + * value: 'abc123', + * description: 'Token for authenticating API requests in staging environment', + * secret: true, // does not appear in git and is stored encrypted + * }); + */ + setEnvironmentVariable( + name: string, + value: string | VariableObject, + environment?: string + ): void; + }; +}