Skip to content

Commit

Permalink
feat: Setup HTML report (#42)
Browse files Browse the repository at this point in the history
* Setup html report generator

* Initial phase of the report (hardcoded content)

* Remove parcel and simplify report generation

* Display screenshots dynamically

* Improve the report's responsiveness, look & feel

* Clean Up

* Clean Up

* Correct path to html report & fix tests

* Write tests for report generation

* Mock progress.cwd

* Clean Up

* Mock fs.mkdir to prevent creating directories during tests

* Prevent test case from creating actual directories
  • Loading branch information
manosim committed Nov 2, 2021
1 parent d055181 commit ac02604
Show file tree
Hide file tree
Showing 16 changed files with 530 additions and 28 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/demo-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 16 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions example/.owl/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
diff/
latest/
report/
3 changes: 2 additions & 1 deletion example/owl.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
"packageName": "com.owldemo",
"quiet": true
},
"debug": true
"debug": true,
"report": true
}
2 changes: 2 additions & 0 deletions lib/cli/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ describe('config.ts', () => {
android: {
packageName: 'com.rndemo',
},
debug: false,
report: true,
};

const filePath = './owl.config.json';
Expand Down
3 changes: 2 additions & 1 deletion lib/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export const validateSchema = (config: {}): Promise<Config> => {
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'] }],
Expand Down
61 changes: 60 additions & 1 deletion lib/cli/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();

Expand All @@ -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();
});
});
});
25 changes: 17 additions & 8 deletions lib/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions lib/matchers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -28,6 +29,7 @@ describe('matchers.ts', () => {

describe('toMatchBaseline.ts', () => {
beforeEach(() => {
mkdirSyncMock.mockReset();
readFileMock.mockReset();
writeFileMock.mockReset();
});
Expand Down
54 changes: 54 additions & 0 deletions lib/report.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '<h1>Hello World<h1>';

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(() => () => '<h1>Hello World Compiled</h1>');

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',
'<h1>Hello World Compiled</h1>'
);
});
});
32 changes: 32 additions & 0 deletions lib/report.ts
Original file line number Diff line number Diff line change
@@ -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}`);
};

0 comments on commit ac02604

Please sign in to comment.