diff --git a/.github/workflows/demo-app.yml b/.github/workflows/demo-app.yml index 481d4eb3..6f769f26 100644 --- a/.github/workflows/demo-app.yml +++ b/.github/workflows/demo-app.yml @@ -63,9 +63,9 @@ jobs: run: yarn owl:test:ios working-directory: ./example - - name: Store screenshots as artifacts + - name: Store screenshots and report as artifacts uses: actions/upload-artifact@v2 if: failure() with: - name: owl-screenshots + name: owl-results path: example/.owl diff --git a/README.md b/README.md index ecac97dd..084bd1a7 100644 --- a/README.md +++ b/README.md @@ -16,21 +16,22 @@ The config file - which unless specified in the cli should live in `./owl.config ### Options -| Name | Required | Description | -| ---------------------- | -------- | -------------------------------------------------------------------- | -| **general** | | | -| `debug` | false | Prevents the CLI/library from printing any logs/output | -| **ios config** | | | -| `ios.workspace` | true | Path to the `.xcworkspace` file of your react-native project | -| `ios.scheme` | true | The name of the scheme you would like to use for building the app | -| `ios.configuration` | true | The build configuration that should be used. Defaults to Debug | -| `ios.buildCommand` | false | Overrides the `xcodebuild` command making the above options obselete | -| `ios.binaryPath` | false | The path to the binary, if you are using a custom build command | -| `ios.quiet` | false | Passes the quiet flag to `xcode builds` | -| **android config** | | | -| `android.buildCommand` | false | Overrides the `assembleDebug` gradle command. Should build the apk | -| `android.binaryPath` | false | The path to the binary, if you are using a custom build command | -| `android.quiet` | false | Passes the quiet flag to `gradlew` | +| Name | Required | Default | Description | +| ---------------------- | -------- | ------- | ----------------------------------------------------------------------- | +| **general** | | | | +| `debug` | false | `false` | Prevents the CLI/library from printing any logs/output. | +| `report` | false | `true` | Generate an HTML report, displaying the baseline, latest & diff images. | +| **ios config** | | | | +| `ios.workspace` | true | | Path to the `.xcworkspace` file of your react-native project | +| `ios.scheme` | true | | The name of the scheme you would like to use for building the app | +| `ios.configuration` | true | `Debug` | The build configuration that should be used. | +| `ios.buildCommand` | false | | Overrides the `xcodebuild` command making the above options obselete | +| `ios.binaryPath` | false | | The path to the binary, if you are using a custom build command | +| `ios.quiet` | false | | Passes the quiet flag to `xcode builds` | +| **android config** | | | | +| `android.buildCommand` | false | | Overrides the `assembleDebug` gradle command. Should build the apk | +| `android.binaryPath` | false | | The path to the binary, if you are using a custom build command | +| `android.quiet` | false | | Passes the quiet flag to `gradlew` | ### Example diff --git a/example/.owl/.gitignore b/example/.owl/.gitignore index 75573e62..f7ee0972 100644 --- a/example/.owl/.gitignore +++ b/example/.owl/.gitignore @@ -1,2 +1,3 @@ diff/ latest/ +report/ diff --git a/example/owl.config.json b/example/owl.config.json index b3f8a1b6..eb6c7736 100644 --- a/example/owl.config.json +++ b/example/owl.config.json @@ -10,5 +10,6 @@ "packageName": "com.owldemo", "quiet": true }, - "debug": true + "debug": true, + "report": true } diff --git a/lib/cli/config.test.ts b/lib/cli/config.test.ts index 8dbdce96..e73bcfe1 100644 --- a/lib/cli/config.test.ts +++ b/lib/cli/config.test.ts @@ -182,6 +182,8 @@ describe('config.ts', () => { android: { packageName: 'com.rndemo', }, + debug: false, + report: true, }; const filePath = './owl.config.json'; diff --git a/lib/cli/config.ts b/lib/cli/config.ts index 32d5ad28..4f2d8351 100644 --- a/lib/cli/config.ts +++ b/lib/cli/config.ts @@ -39,7 +39,8 @@ export const validateSchema = (config: {}): Promise => { nullable: true, additionalProperties: false, }, - debug: { type: 'boolean', nullable: true }, + debug: { type: 'boolean', nullable: true, default: false }, + report: { type: 'boolean', nullable: true, default: true }, }, required: [], anyOf: [{ required: ['ios'] }, { required: ['android'] }], diff --git a/lib/cli/run.test.ts b/lib/cli/run.test.ts index 2a3e497a..2b3242e9 100644 --- a/lib/cli/run.test.ts +++ b/lib/cli/run.test.ts @@ -5,6 +5,7 @@ import { CliRunOptions, Config } from '../types'; import { Logger } from '../logger'; import * as configHelpers from './config'; import * as run from './run'; +import * as reportHelpers from '../report'; describe('run.ts', () => { const logger = new Logger(); @@ -202,11 +203,13 @@ describe('run.ts', () => { )} --roots=${path.join(process.cwd())} --runInBand`; const commandSyncMock = jest.spyOn(execa, 'commandSync'); + const mockGenerateReport = jest.spyOn(reportHelpers, 'generateReport'); jest.spyOn(Logger.prototype, 'print').mockImplementation(); beforeEach(() => { commandSyncMock.mockReset(); + mockGenerateReport.mockReset(); }); it('runs an iOS project', async () => { @@ -247,7 +250,7 @@ describe('run.ts', () => { }); }); - it('runs with the the update baseline flag on', async () => { + it('runs with the update baseline flag on', async () => { jest.spyOn(configHelpers, 'getConfig').mockResolvedValueOnce(config); const mockRunIOS = jest.spyOn(run, 'runIOS').mockResolvedValueOnce(); @@ -264,5 +267,61 @@ describe('run.ts', () => { stdio: 'inherit', }); }); + + it('runs generates the report if the config is set to on', async () => { + const caseConfig: Config = { + ...config, + report: true, + }; + + jest.spyOn(configHelpers, 'getConfig').mockResolvedValueOnce(caseConfig); + const mockRunIOS = jest.spyOn(run, 'runIOS').mockResolvedValueOnce(); + + commandSyncMock.mockRejectedValueOnce(undefined!); + + try { + await run.runHandler({ ...args, update: true }); + } catch { + await expect(mockRunIOS).toHaveBeenCalled(); + await expect(commandSyncMock).toHaveBeenCalledTimes(1); + await expect(mockGenerateReport).toHaveBeenCalledTimes(1); + } + }); + + it('does not generate the report if the config is set to off', async () => { + const caseConfig: Config = { + ...config, + report: false, + }; + + jest.spyOn(configHelpers, 'getConfig').mockResolvedValueOnce(caseConfig); + const mockRunIOS = jest.spyOn(run, 'runIOS').mockResolvedValueOnce(); + + commandSyncMock.mockRejectedValueOnce(undefined!); + + try { + await run.runHandler({ ...args, update: true }); + } catch { + await expect(mockRunIOS).toHaveBeenCalled(); + await expect(commandSyncMock).toHaveBeenCalledTimes(1); + await expect(mockGenerateReport).not.toHaveBeenCalled(); + } + }); + + it('does not generate the report if the tests pass', async () => { + const caseConfig: Config = { + ...config, + report: true, + }; + + jest.spyOn(configHelpers, 'getConfig').mockResolvedValueOnce(caseConfig); + const mockRunIOS = jest.spyOn(run, 'runIOS').mockResolvedValueOnce(); + + await run.runHandler({ ...args, update: true }); + + await expect(mockRunIOS).toHaveBeenCalled(); + await expect(commandSyncMock).toHaveBeenCalledTimes(1); + await expect(mockGenerateReport).not.toHaveBeenCalled(); + }); }); }); diff --git a/lib/cli/run.ts b/lib/cli/run.ts index e039bf2f..9e006eda 100644 --- a/lib/cli/run.ts +++ b/lib/cli/run.ts @@ -2,6 +2,7 @@ import path from 'path'; import execa from 'execa'; import { CliRunOptions, Config } from '../types'; +import { generateReport } from '../report'; import { getConfig } from './config'; import { Logger } from '../logger'; @@ -87,14 +88,22 @@ export const runHandler = async (args: CliRunOptions) => { logger.info(`[OWL] Will use the jest config localed at ${jestConfigPath}.`); logger.info(`[OWL] Will set the jest root to ${process.cwd()}.`); - await execa.commandSync(jestCommand, { - stdio: 'inherit', - env: { - OWL_PLATFORM: args.platform, - OWL_DEBUG: String(!!config.debug), - OWL_UPDATE_BASELINE: String(!!args.update), - }, - }); + try { + await execa.commandSync(jestCommand, { + stdio: 'inherit', + env: { + OWL_PLATFORM: args.platform, + OWL_DEBUG: String(!!config.debug), + OWL_UPDATE_BASELINE: String(!!args.update), + }, + }); + } catch (err) { + if (config.report) { + await generateReport(logger, args.platform); + } + + throw err; + } logger.print(`[OWL] Tests completed on ${args.platform}.`); if (args.update) { diff --git a/lib/matchers.test.ts b/lib/matchers.test.ts index d187de98..bed033d9 100644 --- a/lib/matchers.test.ts +++ b/lib/matchers.test.ts @@ -15,6 +15,7 @@ describe('matchers.ts', () => { const imageHello2Data = `iVBORw0KGgoAAAANSUhEUgAAACUAAAALCAYAAAD4OERFAAAABGdBTUEAALGPC/xhBQAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAJaADAAQAAAABAAAACwAAAADN8bJQAAABsklEQVQ4Ec2UOyhFcRzHj1d5lEEhogyKQqEMyioDxYJBCoMMsngslDvYDQyUYlJsHimURZJQSi6RV15ZxGD0+Hxv///pdDq3bnfhW5/7e/3/5/4fv3Mcx3ESIAX+lWpZzWkcK7pmTiVUw1Uc83uYswZL0ALuwSQT/IXa+dNxgzY3DbkwC06ifpCuMAR3cATlYNWLEzYM2GQUq+dNwDM8wRjo2X7Vk5iEBdiFOWgDV7q+b5iCCliEFZCa4RYaQLVz6AAp6Pr6yR9CIZSAxneBX/kkMjzJZfyQJ3a0qHewp1aHf2kGbGGHjC/TB3bBQYs6od6tgUZa5KYNAmw6uRm4B20kIttTOm6dlvQJaRHPcYqxNTBiYjXjhfGDTBHJA0/hBj/bE3vdMoJ1ULtUwRtEZE/nyyZ89oNYu80x5GEbIZrOKBR4irrCfU9sXW12FYahFdwF4btXJj9IOyT1pqQa5rGDEE3aeSdovBq8CbbBr1ES+hRsgK5QaI4r9ZT3O6WjfDDVLOwevMArqMcyQQrqKZ1SGB5B16xF2lbAdaXaj49jtxqDox3ruEUsSmJQKegNi0u/XtRShUjycDoAAAAASUVORK5CYII=`; const imageHello2Buffer = Buffer.from(imageHello2Data, 'base64'); + const mkdirSyncMock = jest.spyOn(fs, 'mkdirSync').mockImplementation(); const readFileMock = jest.spyOn(fs, 'readFileSync'); const writeFileMock = jest.spyOn(fs, 'writeFileSync'); @@ -28,6 +29,7 @@ describe('matchers.ts', () => { describe('toMatchBaseline.ts', () => { beforeEach(() => { + mkdirSyncMock.mockReset(); readFileMock.mockReset(); writeFileMock.mockReset(); }); diff --git a/lib/report.test.ts b/lib/report.test.ts new file mode 100644 index 00000000..e35b3423 --- /dev/null +++ b/lib/report.test.ts @@ -0,0 +1,54 @@ +import process from 'process'; +import handlebars from 'handlebars'; +import { promises as fs } from 'fs'; + +import { Logger } from './logger'; +import { generateReport } from './report'; + +describe('report.ts', () => { + const logger = new Logger(); + + const htmlTemplate = '

Hello World

'; + + const readdirMock = jest.spyOn(fs, 'readdir'); + const mkdirMock = jest.spyOn(fs, 'mkdir'); + + const readFileMock = jest.spyOn(fs, 'readFile'); + const writeFileMock = jest.spyOn(fs, 'writeFile'); + + const handlebarsCompileMock = jest + .spyOn(handlebars, 'compile') + .mockImplementationOnce(() => () => '

Hello World Compiled

'); + + const cwdMock = jest + .spyOn(process, 'cwd') + .mockReturnValue('/Users/johndoe/Projects/my-project'); + + beforeAll(() => { + readdirMock.mockReset(); + mkdirMock.mockReset(); + + readFileMock.mockReset(); + writeFileMock.mockReset(); + }); + + afterAll(() => { + cwdMock.mockRestore(); + }); + + it('should get the screenshots and create the html report', async () => { + readFileMock.mockResolvedValueOnce(htmlTemplate); + mkdirMock.mockResolvedValueOnce(undefined); + + await generateReport(logger, 'ios'); + + expect(readdirMock).toHaveBeenCalledWith( + '/Users/johndoe/Projects/my-project/.owl/latest/ios' + ); + expect(handlebarsCompileMock).toHaveBeenCalledTimes(1); + expect(writeFileMock).toHaveBeenCalledWith( + '/Users/johndoe/Projects/my-project/.owl/report/index.html', + '

Hello World Compiled

' + ); + }); +}); diff --git a/lib/report.ts b/lib/report.ts new file mode 100644 index 00000000..ba61a79b --- /dev/null +++ b/lib/report.ts @@ -0,0 +1,32 @@ +import path from 'path'; +import handlebars from 'handlebars'; +import { promises as fs } from 'fs'; + +import { Logger } from './logger'; +import { Platform } from './types'; + +export const generateReport = async (logger: Logger, platform: Platform) => { + const cwd = process.cwd(); + const reportDirPath = path.join(cwd, '.owl', 'report'); + const screenshotsDirPath = path.join(cwd, '.owl', 'latest', platform); + const screenshots = await fs.readdir(screenshotsDirPath); + + logger.info(`[OWL] Generating Report`); + + const reportFilename = 'index.html'; + const entryFile = path.join(__dirname, 'report', reportFilename); + const htmlTemplate = await fs.readFile(entryFile, 'utf-8'); + const templateScript = handlebars.compile(htmlTemplate); + const htmlContent = templateScript({ + currentYear: new Date().getFullYear(), + currentDateTime: new Date().toISOString(), + platform, + screenshots, + }); + + await fs.mkdir(reportDirPath, { recursive: true }); + const reportFilePath = path.join(reportDirPath, 'index.html'); + await fs.writeFile(reportFilePath, htmlContent); + + logger.info(`[OWL] Report was built at ${reportDirPath}/${reportFilename}`); +}; diff --git a/lib/report/index.html b/lib/report/index.html new file mode 100644 index 00000000..3054c938 --- /dev/null +++ b/lib/report/index.html @@ -0,0 +1,278 @@ + + + + Report | React Native Owl + + + + + + + + +
+
+

+ Report +

+ +

Generated on {{currentDateTime}}.

+
+
+ +
+ {{#each screenshots}} +
+
+

+ {{this}} +

+ +
+
+

Baseline

+ Baseline screenshot for Homepage +
+ +
+

Latest

+ Latest screenshot for Homepage +
+ +
+

Diff

+ Diff screenshot for Homepage +
+
+
+
+ {{/each}} +
+ + + + diff --git a/lib/take-screenshot.test.ts b/lib/take-screenshot.test.ts index b4cac886..9c998565 100644 --- a/lib/take-screenshot.test.ts +++ b/lib/take-screenshot.test.ts @@ -1,5 +1,6 @@ import execa from 'execa'; import path from 'path'; +import { promises as fs } from 'fs'; import { takeScreenshot } from './take-screenshot'; import * as fileExistsHelpers from './utils/file-exists'; @@ -8,6 +9,11 @@ const SCREENSHOT_FILENAME = 'screen'; describe('take-screenshot.ts', () => { const commandMock = jest.spyOn(execa, 'command'); + const mkdirMock = jest.spyOn(fs, 'mkdir').mockImplementation(); + + const cwdMock = jest + .spyOn(process, 'cwd') + .mockReturnValue('/Users/johndoe/Projects/my-project'); beforeAll(() => { delete process.env.OWL_PLATFORM; @@ -16,6 +22,11 @@ describe('take-screenshot.ts', () => { beforeEach(() => { commandMock.mockReset(); + mkdirMock.mockReset(); + }); + + afterAll(() => { + cwdMock.mockRestore(); }); describe('Baseline', () => { @@ -40,6 +51,16 @@ describe('take-screenshot.ts', () => { stdio: 'ignore', } ); + expect(mkdirMock).toHaveBeenNthCalledWith( + 1, + '/Users/johndoe/Projects/my-project/.owl', + { recursive: true } + ); + expect(mkdirMock).toHaveBeenNthCalledWith( + 2, + '/Users/johndoe/Projects/my-project/.owl/baseline/ios', + { recursive: true } + ); }); }); @@ -60,6 +81,16 @@ describe('take-screenshot.ts', () => { stdio: 'ignore', } ); + expect(mkdirMock).toHaveBeenNthCalledWith( + 1, + '/Users/johndoe/Projects/my-project/.owl', + { recursive: true } + ); + expect(mkdirMock).toHaveBeenNthCalledWith( + 2, + '/Users/johndoe/Projects/my-project/.owl/baseline/android', + { recursive: true } + ); }); }); }); diff --git a/lib/types.ts b/lib/types.ts index 6f3f5a0b..a9f58ec7 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -44,6 +44,8 @@ type ConfigAndroid = { export type Config = { ios?: ConfigIOS; android?: ConfigAndroid; + /** Generate an HTML report, displaying the baseline, latest & diff images. */ + report?: boolean; /** Prevents the CLI/library from printing any logs/output. */ debug?: boolean; }; diff --git a/package.json b/package.json index d6aeb0a3..3ff6afbd 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ ], "scripts": { "build": "tsc", + "postbuild": "mkdir -p dist/report && cp lib/report/index.html dist/report/", "watch": "tsc --watch", "prettier:check": "prettier --check 'lib/**/*.{js,ts,tsx}'", "prettier:apply": "prettier --write 'lib/**/*.{js,ts,tsx}'", @@ -31,6 +32,7 @@ "dependencies": { "ajv": "^7.0.3", "execa": "^5.1.1", + "handlebars": "^4.7.7", "pixelmatch": "^5.2.1", "pngjs": "^6.0.0", "yargs": "^17.2.1" diff --git a/yarn.lock b/yarn.lock index 59a81c6e..1e66e5ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1560,6 +1560,18 @@ growly@^1.3.0: resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= +handlebars@^4.7.7: + version "4.7.7" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" + integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.0" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + har-schema@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" @@ -2594,6 +2606,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +neo-async@^2.6.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -3517,6 +3534,11 @@ typescript@^4.4.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.4.4.tgz#2cd01a1a1f160704d3101fd5a58ff0f9fcb8030c" integrity sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA== +uglify-js@^3.1.4: + version "3.14.2" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.14.2.tgz#d7dd6a46ca57214f54a2d0a43cad0f35db82ac99" + integrity sha512-rtPMlmcO4agTUfz10CbgJ1k6UAoXM2gWb3GoMPPZB/+/Ackf8lNWk11K4rYi2D0apgoFRLtQOZhb+/iGNJq26A== + union-value@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" @@ -3664,6 +3686,11 @@ word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= + wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"