diff --git a/visual-js/visual-snapshots/src/api/visual-snapshots-api.ts b/visual-js/visual-snapshots/src/api/visual-snapshots-api.ts index 6357600a..fa4d5c1b 100644 --- a/visual-js/visual-snapshots/src/api/visual-snapshots-api.ts +++ b/visual-js/visual-snapshots/src/api/visual-snapshots-api.ts @@ -1,4 +1,5 @@ import { BuildStatus, DiffingMethod, VisualApi } from "@saucelabs/visual"; +import { formatString } from "../utils/format.js"; export interface CreateVisualSnapshotsParams { branch: string; @@ -6,7 +7,10 @@ export interface CreateVisualSnapshotsParams { defaultBranch: string; project: string; customId: string; - buildId: string; + buildId?: string; + suiteName?: string; + testName?: string; + snapshotName?: string; } export class VisualSnapshotsApi { @@ -17,17 +21,30 @@ export class VisualSnapshotsApi { } public async generateAndSendPdfFileSnapshots( + filename: string, pdfFilePages: AsyncGenerator, params: CreateVisualSnapshotsParams ) { - const buildId = await this.createBuild(params); + const buildId = params.buildId ?? (await this.createBuild(params)); + const testName = params.testName + ? formatString(params.testName, { filename }) + : undefined; + + const snapshotFormat = this.getSnapshotFormat(params.snapshotName); let pageNumber = 1; for await (const pdfPageImage of pdfFilePages) { + const snapshotName = formatString(snapshotFormat, { + filename, + page: pageNumber, + }); + await this.uploadImageAndCreateSnapshot( pdfPageImage, buildId, - `page-${pageNumber}` + snapshotName, + testName, + params.suiteName ); pageNumber++; } @@ -52,7 +69,9 @@ export class VisualSnapshotsApi { private async uploadImageAndCreateSnapshot( snapshot: Buffer, buildId: string, - snapshotName: string + snapshotName: string, + testName?: string, + suiteName?: string ) { const uploadId = await this.api.uploadSnapshot({ buildId, @@ -66,6 +85,8 @@ export class VisualSnapshotsApi { uploadId, name: snapshotName, diffingMethod: DiffingMethod.Balanced, + testName, + suiteName, }); console.info(`Created a snapshot ${snapshotName} for build ${buildId}.`); @@ -90,4 +111,17 @@ export class VisualSnapshotsApi { ); } } + + private getSnapshotFormat(format: string | undefined) { + if (!format) { + return `page-{page}`; + } + + // Page number is always required to make the snapshot names unique + if (!format.includes("{page}")) { + format = format += "-{page}"; + } + + return format; + } } diff --git a/visual-js/visual-snapshots/src/app/pdf-handler.ts b/visual-js/visual-snapshots/src/app/pdf-handler.ts index 06b5c762..4d0b27e2 100644 --- a/visual-js/visual-snapshots/src/app/pdf-handler.ts +++ b/visual-js/visual-snapshots/src/app/pdf-handler.ts @@ -5,6 +5,7 @@ import { import { initializeVisualApi } from "../api/visual-client.js"; import { PdfConverter } from "./pdf-converter.js"; import { VisualConfig } from "@saucelabs/visual"; +import path from "path"; export interface PdfCommandParams extends VisualConfig, @@ -24,6 +25,7 @@ export class PdfCommandHandler { const pdfPageImages = pdfConverter.convertPagesToImages(pdfFilePath); await visualSnapshots.generateAndSendPdfFileSnapshots( + path.basename(pdfFilePath), pdfPageImages, params ); diff --git a/visual-js/visual-snapshots/src/commands/options.ts b/visual-js/visual-snapshots/src/commands/options.ts index 3e37c743..68c365c5 100644 --- a/visual-js/visual-snapshots/src/commands/options.ts +++ b/visual-js/visual-snapshots/src/commands/options.ts @@ -1,5 +1,6 @@ import { Option } from "commander"; import { EOL } from "os"; +import { parseUuid } from "./validate.js"; export const usernameOption = new Option( "-u, --user ", @@ -63,7 +64,9 @@ export const buildIdOption = new Option( "By default, this is not set and we create / finish a build during setup / teardown." + EOL + "If not provided, SAUCE_VISUAL_BUILD_ID environment variable will be used." -).env("SAUCE_VISUAL_BUILD_ID"); +) + .env("SAUCE_VISUAL_BUILD_ID") + .argParser(parseUuid); export const customIdOption = new Option( "--custom-id ", @@ -71,3 +74,8 @@ export const customIdOption = new Option( EOL + "If not provided, SAUCE_VISUAL_CUSTOM_ID environment variable will be used." ).env("SAUCE_VISUAL_CUSTOM_ID"); + +export const suiteNameOption = new Option( + "--suite-name ", + "The name of the suite you would like to appear in the Sauce Visual dashboard." +); diff --git a/visual-js/visual-snapshots/src/commands/pdf.ts b/visual-js/visual-snapshots/src/commands/pdf.ts index 8bd01bc6..0dd48a75 100644 --- a/visual-js/visual-snapshots/src/commands/pdf.ts +++ b/visual-js/visual-snapshots/src/commands/pdf.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { accessKeyOption, branchOption, @@ -8,9 +8,25 @@ import { defaultBranchOption, projectOption, regionOption, + suiteNameOption, usernameOption, } from "./options.js"; import { PdfCommandHandler, PdfCommandParams } from "../app/pdf-handler.js"; +import { EOL } from "os"; + +export const testNameOption = new Option( + "--test-name ", + "The name of the test you would like to appear in the Sauce Visual dashboard." + + EOL + + "Supports the following parameters: {filename}" +); + +export const snapshotNameOption = new Option( + "--snapshot-name ", + "The name of the snapshot you would like to appear in the Sauce Visual dashboard." + + EOL + + " Supports the following parameters: {filename}, {page}" +); export const pdfCommand = (clientVersion: string) => { return new Command() @@ -26,6 +42,9 @@ export const pdfCommand = (clientVersion: string) => { .addOption(projectOption) .addOption(buildIdOption) .addOption(customIdOption) + .addOption(suiteNameOption) + .addOption(testNameOption) + .addOption(snapshotNameOption) .action((pdfFilePath: string, params: PdfCommandParams) => { new PdfCommandHandler(clientVersion) .handle(pdfFilePath, params) diff --git a/visual-js/visual-snapshots/src/commands/validate.ts b/visual-js/visual-snapshots/src/commands/validate.ts new file mode 100644 index 00000000..39b78e05 --- /dev/null +++ b/visual-js/visual-snapshots/src/commands/validate.ts @@ -0,0 +1,25 @@ +import { InvalidArgumentError } from "commander"; + +const UUID_REGEX = + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; +const DASHLESS_UUID_REGEX = /^[a-f0-9]{32}$/i; + +export function parseUuid(input: string) { + if (UUID_REGEX.test(input)) { + return input; + } + + if (DASHLESS_UUID_REGEX.test(input)) { + return ( + `${input.substring(0, 8)}-` + + `${input.substring(8, 12)}-` + + `${input.substring(12, 16)}-` + + `${input.substring(16, 20)}-` + + `${input.substring(20, 32)}` + ); + } + + throw new InvalidArgumentError( + "Expected UUID in form of xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx or 32 hexadecimal characters." + ); +} diff --git a/visual-js/visual-snapshots/src/utils/format.ts b/visual-js/visual-snapshots/src/utils/format.ts new file mode 100644 index 00000000..cc11b1c6 --- /dev/null +++ b/visual-js/visual-snapshots/src/utils/format.ts @@ -0,0 +1,13 @@ +/** + * Replaces all occurrences of keys in format of `{key}` in `value` with `data[key]`. + * + * If `key` does not exist in data, it is left as it is. + */ +export function formatString( + value: string, + data: Record +) { + return Object.entries(data) + .map(([k, v]) => [k, v.toString()] as const) + .reduce((current, [k, v]) => current.replaceAll(`{${k}}`, v), value); +} diff --git a/visual-js/visual-snapshots/test/api/__snapshots__/visual-api.spec.ts.snap b/visual-js/visual-snapshots/test/api/__snapshots__/visual-api.spec.ts.snap index 72dbb15f..b5b98871 100644 --- a/visual-js/visual-snapshots/test/api/__snapshots__/visual-api.spec.ts.snap +++ b/visual-js/visual-snapshots/test/api/__snapshots__/visual-api.spec.ts.snap @@ -1,5 +1,51 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`VisualSnapshots generateAndSendPdfFileSnapshots with params and build-id difffing finished 1`] = ` +[ + [ + "Uploaded image to build custom-build-id: upload id=upload-id-0.", + ], + [ + "Created a snapshot custom-snapshot-name-filename.pdf-1 for build custom-build-id.", + ], + [ + "Uploaded image to build custom-build-id: upload id=upload-id-1.", + ], + [ + "Created a snapshot custom-snapshot-name-filename.pdf-2 for build custom-build-id.", + ], + [ + "Build custom-build-id finished.", + ], + [ + "Build custom-build-id finished (status=APPROVED, unapprovedCount=0, errorCount=0).", + ], +] +`; + +exports[`VisualSnapshots generateAndSendPdfFileSnapshots with params and build-id difffing unfinished 1`] = ` +[ + [ + "Uploaded image to build custom-build-id: upload id=upload-id-0.", + ], + [ + "Created a snapshot custom-snapshot-name-filename.pdf-1 for build custom-build-id.", + ], + [ + "Uploaded image to build custom-build-id: upload id=upload-id-1.", + ], + [ + "Created a snapshot custom-snapshot-name-filename.pdf-2 for build custom-build-id.", + ], + [ + "Build custom-build-id finished.", + ], + [ + "Build custom-build-id finished but snapshots haven't been compared yet. Check the build status in a few moments.", + ], +] +`; + exports[`VisualSnapshots generateAndSendPdfFileSnapshots with params difffing finished 1`] = ` [ [ @@ -9,13 +55,13 @@ exports[`VisualSnapshots generateAndSendPdfFileSnapshots with params difffing fi "Uploaded image to build build-id: upload id=upload-id-0.", ], [ - "Created a snapshot page-1 for build build-id.", + "Created a snapshot custom-snapshot-name-filename.pdf-1 for build build-id.", ], [ "Uploaded image to build build-id: upload id=upload-id-1.", ], [ - "Created a snapshot page-2 for build build-id.", + "Created a snapshot custom-snapshot-name-filename.pdf-2 for build build-id.", ], [ "Build build-id finished.", @@ -35,13 +81,13 @@ exports[`VisualSnapshots generateAndSendPdfFileSnapshots with params difffing un "Uploaded image to build build-id: upload id=upload-id-0.", ], [ - "Created a snapshot page-1 for build build-id.", + "Created a snapshot custom-snapshot-name-filename.pdf-1 for build build-id.", ], [ "Uploaded image to build build-id: upload id=upload-id-1.", ], [ - "Created a snapshot page-2 for build build-id.", + "Created a snapshot custom-snapshot-name-filename.pdf-2 for build build-id.", ], [ "Build build-id finished.", diff --git a/visual-js/visual-snapshots/test/api/visual-api.spec.ts b/visual-js/visual-snapshots/test/api/visual-api.spec.ts index 58707c79..3dc3368c 100644 --- a/visual-js/visual-snapshots/test/api/visual-api.spec.ts +++ b/visual-js/visual-snapshots/test/api/visual-api.spec.ts @@ -3,6 +3,7 @@ import { CreateVisualSnapshotsParams, VisualSnapshotsApi, } from "../../src/api/visual-snapshots-api.js"; +import { formatString } from "../../src/utils/format.js"; async function* pdfPagesGenerator(): AsyncGenerator { for (let i = 0; i < 2; ++i) { @@ -34,13 +35,14 @@ describe("VisualSnapshots", () => { const visualSnapshots = new VisualSnapshotsApi(visualApiMock); beforeEach(() => { createBuildMock.mockReset(); - createBuildMock.mockReturnValueOnce( - Promise.resolve({ id: "build-id", url: "http://build-url/build-id" }) - ); + createBuildMock.mockResolvedValueOnce({ + id: "build-id", + url: "http://build-url/build-id", + }); uploadSnapshotMock.mockReset(); uploadSnapshotMock - .mockReturnValueOnce(Promise.resolve("upload-id-0")) - .mockReturnValueOnce(Promise.resolve("upload-id-1")); + .mockResolvedValueOnce("upload-id-0") + .mockResolvedValueOnce("upload-id-1"); createSnapshotMock.mockReset(); finishBuildMock.mockReset(); buildStatusMock.mockReset(); @@ -50,26 +52,29 @@ describe("VisualSnapshots", () => { }); const assertSuccessfulPdfSnapshotsGeneration = ( + filename: string, params: CreateVisualSnapshotsParams ) => { - expect(createBuildMock).toHaveBeenCalledWith({ - name: params.buildName, - branch: params.branch, - defaultBranch: params.defaultBranch, - project: params.project, - customId: params.customId, - }); + if (!params.buildId) { + expect(createBuildMock).toHaveBeenCalledWith({ + name: params.buildName, + branch: params.branch, + defaultBranch: params.defaultBranch, + project: params.project, + customId: params.customId, + }); + } expect(uploadSnapshotMock.mock.calls).toEqual([ [ { - buildId: "build-id", + buildId: params.buildId ?? "build-id", image: { data: Buffer.from("fake-image-buffer-0") }, }, ], [ { - buildId: "build-id", + buildId: params.buildId ?? "build-id", image: { data: Buffer.from("fake-image-buffer-1") }, }, ], @@ -79,82 +84,160 @@ describe("VisualSnapshots", () => { [ { diffingMethod: DiffingMethod.Balanced, - buildId: "build-id", - name: "page-1", + buildId: params.buildId ?? "build-id", + name: formatString(params.snapshotName ?? "page-{page}", { + filename, + page: 1, + }), uploadId: "upload-id-0", + suiteName: params.suiteName, + testName: params.testName + ? formatString(params.testName, { + filename, + }) + : undefined, }, ], [ { diffingMethod: DiffingMethod.Balanced, - buildId: "build-id", - name: "page-2", + buildId: params.buildId ?? "build-id", + name: formatString(params.snapshotName ?? "page-{page}", { + filename, + page: 2, + }), uploadId: "upload-id-1", + suiteName: params.suiteName, + testName: params.testName + ? formatString(params.testName, { + filename, + }) + : undefined, }, ], ]); expect(finishBuildMock).toHaveBeenCalledWith({ - uuid: "build-id", + uuid: params.buildId ?? "build-id", }); - expect(buildStatusMock).toHaveBeenCalledWith("build-id"); + expect(buildStatusMock).toHaveBeenCalledWith( + params.buildId ?? "build-id" + ); expect(consoleInfoSpy.mock.calls).toMatchSnapshot(); }; describe("with params", () => { + const filename = "filename.pdf"; const params = { branch: "fake-branch", buildName: "fake-build-name", defaultBranch: "fake-default-branch", project: "fake-project", customId: "fake-custom-id", - buildId: "fake-build-id", - } as CreateVisualSnapshotsParams; + snapshotName: "custom-snapshot-name-{filename}-{page}", + suiteName: "custom-suite-name", + testName: "custom-test-name-{filename}", + } satisfies CreateVisualSnapshotsParams; test("difffing unfinished", async () => { - buildStatusMock.mockReturnValueOnce( - Promise.resolve({ - status: BuildStatus.Running, - unapprovedCount: 2, - errorCount: 0, - }) - ); + buildStatusMock.mockResolvedValueOnce({ + status: BuildStatus.Running, + unapprovedCount: 2, + errorCount: 0, + }); - await visualSnapshots.generateAndSendPdfFileSnapshots(pdfPages, params); + await visualSnapshots.generateAndSendPdfFileSnapshots( + filename, + pdfPages, + params + ); - assertSuccessfulPdfSnapshotsGeneration(params); + assertSuccessfulPdfSnapshotsGeneration(filename, params); }); test("difffing finished", async () => { - buildStatusMock.mockReturnValueOnce( - Promise.resolve({ - status: BuildStatus.Approved, - unapprovedCount: 0, - errorCount: 0, - }) - ); + buildStatusMock.mockResolvedValueOnce({ + status: BuildStatus.Approved, + unapprovedCount: 0, + errorCount: 0, + }); - await visualSnapshots.generateAndSendPdfFileSnapshots(pdfPages, params); + await visualSnapshots.generateAndSendPdfFileSnapshots( + filename, + pdfPages, + params + ); - assertSuccessfulPdfSnapshotsGeneration(params); + assertSuccessfulPdfSnapshotsGeneration(filename, params); }); }); - test("without params", async () => { - buildStatusMock.mockReturnValueOnce( - Promise.resolve({ - status: BuildStatus.Unapproved, + describe("with params and build-id", () => { + const filename = "filename.pdf"; + const params = { + branch: "fake-branch", + buildName: "fake-build-name", + defaultBranch: "fake-default-branch", + project: "fake-project", + customId: "fake-custom-id", + buildId: "custom-build-id", + snapshotName: "custom-snapshot-name-{filename}-{page}", + suiteName: "custom-suite-name", + testName: "custom-test-name-{filename}", + } satisfies CreateVisualSnapshotsParams; + + test("difffing unfinished", async () => { + buildStatusMock.mockResolvedValueOnce({ + status: BuildStatus.Running, unapprovedCount: 2, errorCount: 0, - }) - ); + }); + + await visualSnapshots.generateAndSendPdfFileSnapshots( + filename, + pdfPages, + params + ); + + assertSuccessfulPdfSnapshotsGeneration(filename, params); + }); + + test("difffing finished", async () => { + buildStatusMock.mockResolvedValueOnce({ + status: BuildStatus.Approved, + unapprovedCount: 0, + errorCount: 0, + }); + + await visualSnapshots.generateAndSendPdfFileSnapshots( + filename, + pdfPages, + params + ); + + assertSuccessfulPdfSnapshotsGeneration(filename, params); + }); + }); + + test("without params", async () => { + const filename = "filename.pdf"; + + buildStatusMock.mockResolvedValueOnce({ + status: BuildStatus.Unapproved, + unapprovedCount: 2, + errorCount: 0, + }); const params = {} as CreateVisualSnapshotsParams; - await visualSnapshots.generateAndSendPdfFileSnapshots(pdfPages, params); + await visualSnapshots.generateAndSendPdfFileSnapshots( + filename, + pdfPages, + params + ); - assertSuccessfulPdfSnapshotsGeneration(params); + assertSuccessfulPdfSnapshotsGeneration(filename, params); }); }); }); diff --git a/visual-js/visual-snapshots/test/utils/format.spec.ts b/visual-js/visual-snapshots/test/utils/format.spec.ts new file mode 100644 index 00000000..2c08769e --- /dev/null +++ b/visual-js/visual-snapshots/test/utils/format.spec.ts @@ -0,0 +1,19 @@ +import { formatString } from "../../src/utils/format.js"; + +describe("formatString", () => { + it("should replace all occurences of key with data from object", () => { + const value = "foo {foo} bar {foo} {bar}"; + const data = { foo: "xyz", bar: 123 }; + const expected = "foo xyz bar xyz 123"; + + expect(formatString(value, data)).toEqual(expected); + }); + + it("should not replace keys that do not exist in data", () => { + const value = "foo {foo}"; + const data = { bar: 123 }; + const expected = "foo {foo}"; + + expect(formatString(value, data)).toEqual(expected); + }); +});