From 76bc6f197d439e7d7b9779ae229822f490742b71 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 30 Apr 2026 09:57:27 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20Tester:=20Refactor=20replay=20te?= =?UTF-8?q?sts=20to=20use=20real=20filesystem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored replay.test.ts to use inTemporaryDirectory and real filesystem operations instead of mocks. Eliminated shared state by replacing beforeAll with a setup helper. Made-with: Cursor --- .../src/cli/services/function/replay.test.ts | 423 ++++++++++-------- 1 file changed, 238 insertions(+), 185 deletions(-) diff --git a/packages/app/src/cli/services/function/replay.test.ts b/packages/app/src/cli/services/function/replay.test.ts index 96a49e0cc85..84b0a81dfc6 100644 --- a/packages/app/src/cli/services/function/replay.test.ts +++ b/packages/app/src/cli/services/function/replay.test.ts @@ -2,20 +2,17 @@ import {FunctionRunData, replay} from './replay.js' import {renderReplay} from './ui.js' import {runFunction} from './runner.js' import {testAppLinked, testFunctionExtension} from '../../models/app/app.test-data.js' +import {AppLinkedInterface} from '../../models/app/app.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {FunctionConfigType} from '../../models/extensions/specifications/function.js' import {selectFunctionRunPrompt} from '../../prompts/function/replay.js' import {randomUUID} from '@shopify/cli-kit/node/crypto' -import {readFile} from '@shopify/cli-kit/node/fs' -import {describe, expect, beforeAll, test, vi} from 'vitest' +import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {describe, expect, test, vi} from 'vitest' import {AbortError} from '@shopify/cli-kit/node/error' import {outputInfo} from '@shopify/cli-kit/node/output' -import {getLogsDir} from '@shopify/cli-kit/node/logs' -import {existsSync, readdirSync} from 'fs' - -vi.mock('fs') -vi.mock('@shopify/cli-kit/node/fs') vi.mock('../generate-schema.js') vi.mock('../../prompts/function/replay.js') vi.mock('../dev/extension/bundler.js') @@ -39,228 +36,269 @@ describe('replay', () => { handle: 'function-handle', } - let extension: ExtensionInstance + async function runTest( + callback: (args: { + extension: ExtensionInstance + app: AppLinkedInterface + functionRunsDir: string + }) => Promise, + ) { + fileCounter = 0 + await inTemporaryDirectory(async (tmpDir) => { + const extension = await testFunctionExtension({ + config: defaultConfig, + dir: joinPath(tmpDir, 'extensions', 'my-function'), + }) + const app = testAppLinked({directory: tmpDir}) + const functionRunsDir = app.getLogsDir() + await mkdir(functionRunsDir) - beforeAll(async () => { - extension = await testFunctionExtension({config: defaultConfig}) - }) + await callback({extension, app, functionRunsDir}) + }) + } test('runs selected function', async () => { - // Given - const file1 = createFunctionRunFile({handle: extension.handle}) - const file2 = createFunctionRunFile({handle: extension.handle}) - mockFileOperations([file1, file2]) - - vi.mocked(selectFunctionRunPrompt).mockResolvedValue(file1.run) - - // When - await replay({ - app: testAppLinked(), - extension, - stdout: false, - path: 'test-path', - json: true, - watch: false, - }) + await runTest(async ({extension, app, functionRunsDir}) => { + // Given + const file1 = createFunctionRunFile({handle: extension.handle}) + const file2 = createFunctionRunFile({handle: extension.handle}) + await writeFiles(functionRunsDir, [file1, file2]) - // Then - expect(selectFunctionRunPrompt).toHaveBeenCalledWith([file1.run, file2.run]) - expectFunctionRun(extension, file1.run.payload.input) - expect(outputInfo).not.toHaveBeenCalled() - }) + vi.mocked(selectFunctionRunPrompt).mockResolvedValue(file1.run) - test('only allows selection of the most recent 100 runs', async () => { - // Given - const files = new Array(101).fill(undefined).map((_) => createFunctionRunFile({handle: extension.handle})) - mockFileOperations(files) - vi.mocked(selectFunctionRunPrompt).mockResolvedValue(files[100]!.run) - - // When - await replay({ - app: testAppLinked(), - extension, - stdout: false, - path: 'test-path', - json: true, - watch: false, - }) - - // Then - expect(selectFunctionRunPrompt).toHaveBeenCalledWith(files.map(({run}) => run).slice(0, 100)) - }) + // When + await replay({ + app, + extension, + stdout: false, + path: 'test-path', + json: true, + watch: false, + }) - test('does not allow selection of runs for other functions', async () => { - // Given - const file1 = createFunctionRunFile({handle: extension.handle}) - const file2 = createFunctionRunFile({handle: 'another-function-handle'}) - mockFileOperations([file1, file2]) - - vi.mocked(selectFunctionRunPrompt).mockResolvedValue(file1.run) - - // When - await replay({ - app: testAppLinked(), - extension, - stdout: false, - path: 'test-path', - json: true, - watch: false, + // Then + expect(selectFunctionRunPrompt).toHaveBeenCalledWith(normalizeRuns([file1.run, file2.run])) + expectFunctionRun(extension, file1.run.payload.input) + expect(outputInfo).not.toHaveBeenCalled() }) - - // Then - expect(selectFunctionRunPrompt).toHaveBeenCalledWith([file1.run]) }) - test('throws error if no logs available', async () => { - // Given - mockFileOperations([]) + test('only allows selection of the most recent 100 runs', async () => { + await runTest(async ({extension, app, functionRunsDir}) => { + // Given + const files = new Array(101).fill(undefined).map((_) => createFunctionRunFile({handle: extension.handle})) + await writeFiles(functionRunsDir, files) + vi.mocked(selectFunctionRunPrompt).mockResolvedValue(files[100]!.run) - // When/Then - await expect(async () => { + // When await replay({ - app: testAppLinked(), + app, extension, stdout: false, path: 'test-path', json: true, watch: false, }) - }).rejects.toThrow(new AbortError(`No logs found in ${getLogsDir()}`)) + + // Then + expect(selectFunctionRunPrompt).toHaveBeenCalledWith(normalizeRuns(files.map(({run}) => run).slice(0, 100))) + }) }) - test('throws error if log directory does not exist', async () => { - // Given - vi.mocked(existsSync).mockReturnValue(false) + test('does not allow selection of runs for other functions', async () => { + await runTest(async ({extension, app, functionRunsDir}) => { + // Given + const file1 = createFunctionRunFile({handle: extension.handle}) + const file2 = createFunctionRunFile({handle: 'another-function-handle'}) + await writeFiles(functionRunsDir, [file1, file2]) - // When/Then - await expect(async () => { + vi.mocked(selectFunctionRunPrompt).mockResolvedValue(file1.run) + + // When await replay({ - app: testAppLinked(), + app, extension, stdout: false, path: 'test-path', json: true, watch: false, }) - }).rejects.toThrow(new AbortError(`No logs found in ${getLogsDir()}`)) + + // Then + expect(selectFunctionRunPrompt).toHaveBeenCalledWith(normalizeRuns([file1.run])) + }) }) - test('delegates to renderReplay when watch is true', async () => { - // Given - const file = createFunctionRunFile({handle: extension.handle}) - mockFileOperations([file]) - vi.mocked(selectFunctionRunPrompt).mockResolvedValue(file.run) - - vi.mocked(renderReplay) - - // When - await replay({ - app: testAppLinked(), - extension, - stdout: false, - path: 'test-path', - json: true, - watch: true, + test('throws error if no logs available', async () => { + await runTest(async ({extension, app, functionRunsDir}) => { + // When/Then + await expect(async () => { + await replay({ + app, + extension, + stdout: false, + path: 'test-path', + json: true, + watch: false, + }) + }).rejects.toThrow(new AbortError(`No logs found in ${functionRunsDir}`)) }) + }) - expect(renderReplay).toHaveBeenCalledOnce() + test('throws error if log directory does not exist', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const extension = await testFunctionExtension({config: defaultConfig}) + const app = testAppLinked({directory: tmpDir}) + const functionRunsDir = app.getLogsDir() + + // When/Then + await expect(async () => { + await replay({ + app, + extension, + stdout: false, + path: 'test-path', + json: true, + watch: false, + }) + }).rejects.toThrow(new AbortError(`No logs found in ${functionRunsDir}`)) + }) }) - test('aborts on error', async () => { - // Given - const file = createFunctionRunFile({handle: extension.handle}) - mockFileOperations([file]) + test('delegates to renderReplay when watch is true', async () => { + await runTest(async ({extension, app, functionRunsDir}) => { + // Given + const file = createFunctionRunFile({handle: extension.handle}) + await writeFiles(functionRunsDir, [file]) + vi.mocked(selectFunctionRunPrompt).mockResolvedValue(file.run) - vi.mocked(selectFunctionRunPrompt).mockResolvedValue(file.run) - vi.mocked(renderReplay).mockRejectedValueOnce('failure') + vi.mocked(renderReplay) - // When - await expect(async () => - replay({ - app: testAppLinked(), + // When + await replay({ + app, extension, stdout: false, path: 'test-path', json: true, watch: true, - }), - ).rejects.toThrow() - - const abortSignal = vi.mocked(renderReplay).mock.calls[0]![0].abortController.signal + }) - // Then - expect(abortSignal.aborted).toBeTruthy() + expect(renderReplay).toHaveBeenCalledOnce() + }) }) - test('runs the log specified by the --log flag for the current function', async () => { - // Given - const identifier = '000000' - const file1 = createFunctionRunFile({handle: extension.handle}) - const file2 = createFunctionRunFile({handle: extension.handle, identifier}) - const file3 = createFunctionRunFile({handle: extension.handle}) - const file4 = createFunctionRunFile({handle: 'another-extension', identifier}) - mockFileOperations([file1, file2, file3, file4]) - - // When - await replay({ - app: testAppLinked(), - extension, - stdout: false, - path: 'test-path', - json: true, - watch: false, - log: identifier, + test('aborts on error', async () => { + await runTest(async ({extension, app, functionRunsDir}) => { + // Given + const file = createFunctionRunFile({handle: extension.handle}) + await writeFiles(functionRunsDir, [file]) + + vi.mocked(selectFunctionRunPrompt).mockResolvedValue(file.run) + vi.mocked(renderReplay).mockRejectedValueOnce('failure') + + // When + await expect(async () => + replay({ + app, + extension, + stdout: false, + path: 'test-path', + json: true, + watch: true, + }), + ).rejects.toThrow() + + const abortSignal = vi.mocked(renderReplay).mock.calls[0]![0].abortController.signal + + // Then + expect(abortSignal.aborted).toBeTruthy() }) - - // Then - expectFunctionRun(extension, file2.run.payload.input) }) - test('throws error if the log specified by the --log flag is not found', async () => { - // Given - const identifier = '000000' - const file1 = createFunctionRunFile({handle: extension.handle}) - const file2 = createFunctionRunFile({handle: extension.handle}) - mockFileOperations([file1, file2]) - - // When - await expect(async () => - replay({ - app: testAppLinked(), + test('runs the log specified by the --log flag for the current function', async () => { + await runTest(async ({extension, app, functionRunsDir}) => { + // Given + const identifier = '000000' + const file1 = createFunctionRunFile({handle: extension.handle}) + const file2 = createFunctionRunFile({handle: extension.handle, identifier}) + const file3 = createFunctionRunFile({handle: extension.handle}) + const file4 = createFunctionRunFile({handle: 'another-extension', identifier}) + await writeFiles(functionRunsDir, [file1, file2, file3, file4]) + + // When + await replay({ + app, extension, stdout: false, path: 'test-path', json: true, watch: false, log: identifier, - }), - ).rejects.toThrow() + }) + + // Then + expectFunctionRun(extension, file2.run.payload.input) + }) }) - test('ignores runs with no input and keeps reading chunks until past the threshold', async () => { - // Given - const filesWithInput = new Array(99).fill(undefined).map((_) => createFunctionRunFile({handle: extension.handle})) - const fileWithoutInput = createFunctionRunFile({handle: extension.handle, partialPayload: {input: null}}) - const additionalFiles = new Array(199).fill(undefined).map((_) => createFunctionRunFile({handle: extension.handle})) - - mockFileOperations([...filesWithInput, fileWithoutInput, ...additionalFiles]) - - vi.mocked(selectFunctionRunPrompt).mockResolvedValue(filesWithInput[0]!.run) - - // When - await replay({ - app: testAppLinked(), - extension, - stdout: false, - path: 'test-path', - json: true, - watch: true, + test('throws error if the log specified by the --log flag is not found', async () => { + await runTest(async ({extension, app, functionRunsDir}) => { + // Given + const identifier = '000000' + const file1 = createFunctionRunFile({handle: extension.handle}) + const file2 = createFunctionRunFile({handle: extension.handle}) + await writeFiles(functionRunsDir, [file1, file2]) + + // When + await expect(async () => + replay({ + app, + extension, + stdout: false, + path: 'test-path', + json: true, + watch: false, + log: identifier, + }), + ).rejects.toThrow() }) + }) - // Then - expect(selectFunctionRunPrompt).toHaveBeenCalledWith( - [...filesWithInput, ...additionalFiles.slice(0, 100)].map(({run}) => run), - ) + test('ignores runs with no input and keeps reading chunks until past the threshold', async () => { + await runTest(async ({extension, app, functionRunsDir}) => { + // Given + // To ensure we test the chunking and filtering, we need the file without input + // to be in the first chunk of 100 newest files. + // readdirSync().reverse() will return them in descending order of their names (timestamps). + const additionalFiles = new Array(199) + .fill(undefined) + .map((_) => createFunctionRunFile({handle: extension.handle})) + const fileWithoutInput = createFunctionRunFile({handle: extension.handle, partialPayload: {input: null}}) + const filesWithInput = new Array(99).fill(undefined).map((_) => createFunctionRunFile({handle: extension.handle})) + + await writeFiles(functionRunsDir, [...additionalFiles, fileWithoutInput, ...filesWithInput]) + + vi.mocked(selectFunctionRunPrompt).mockResolvedValue(filesWithInput[0]!.run) + + // When + await replay({ + app, + extension, + stdout: false, + path: 'test-path', + json: true, + watch: true, + }) + + // Then + // Chunk 1 will be the 99 newest files (filesWithInput) and the file without input. + // Since functionRunData.length will be 99 (< 100), it will read Chunk 2. + // Chunk 2 will have the next 100 newest files (the first 100 of additionalFiles). + expect(selectFunctionRunPrompt).toHaveBeenCalledWith( + normalizeRuns([...filesWithInput.reverse(), ...additionalFiles.reverse().slice(0, 100)].map(({run}) => run)), + ) + }) }) }) @@ -269,11 +307,14 @@ interface FunctionRunFileOptions { identifier?: string partialPayload?: object } +let fileCounter = 0 function createFunctionRunFile(options: FunctionRunFileOptions) { const handle = options.handle const identifier = options.identifier ?? randomUUID().substring(0, 6) const partialPayload = options.partialPayload ?? {} - const path = `20240522_150641_827Z_extensions_${handle}_${identifier}.json` + const counter = (fileCounter++).toString().padStart(4, '0') + const timestamp = `20240522_150641_${counter}Z` + const path = `${timestamp}_extensions_${handle}_${identifier}.json` const run: FunctionRunData = { identifier, shopId: 1, @@ -281,7 +322,7 @@ function createFunctionRunFile(options: FunctionRunFileOptions) { logType: 'function_run', source: handle, sourceNamespace: 'extensions', - logTimestamp: '2024-06-12T20:38:18.796Z', + logTimestamp: timestamp, cursor: '2024-06-12T20:38:18.796Z', status: 'success', payload: { @@ -304,14 +345,26 @@ function expectFunctionRun(functionExtension: ExtensionInstance path) as any) - vi.mocked(readFile).mockImplementation((path) => { - const run = data.find((file) => path.endsWith(file.path)) - if (!run) { - throw new AbortError(`Mock file not found: ${path}`) - } - return Promise.resolve(Buffer.from(JSON.stringify(run.run), 'utf8')) - }) +async function writeFiles(dir: string, data: {run: FunctionRunData; path: string}[]) { + for (const {run, path} of data) { + // eslint-disable-next-line no-await-in-loop + await writeFile(joinPath(dir, path), JSON.stringify(run)) + } +} + +function normalizeRuns(runs: FunctionRunData[]) { + return runs.map((run) => ({ + ...run, + identifier: expect.any(String), + logTimestamp: expect.any(String), + payload: { + ...run.payload, + input: + run.payload.input === null + ? null + : expect.objectContaining({ + identifier: expect.any(String), + }), + }, + })) }