Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions __tests__/inputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ describe('parseInputs', () => {
expect(i.flashlightInitScripts).toBe('');
expect(i.prComment).toBe(false);
expect(i.uploadArtifacts).toBe(true);
expect(i.visual).toBe(true);
});
});

it('parses visual input', () => {
withInputs({ token: 't', visual: 'true' }, 'push', () => {
expect(parseInputs().visual).toBe(true);
});
withInputs({ token: 't', visual: 'false' }, 'push', () => {
expect(parseInputs().visual).toBe(false);
});
withInputs({ token: 't', visual: '' }, 'push', () => {
expect(parseInputs().visual).toBe(true);
});
});

Expand Down
41 changes: 39 additions & 2 deletions __tests__/upload/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,24 @@ const postMock = jest.fn().mockResolvedValue({
data: { id: 'r_42', url: 'https://app.prestaflow.io/reports/r_42' },
});
jest.mock('axios', () => ({ __esModule: true, default: { post: (...a: unknown[]) => postMock(...a) } }));

let globFiles: string[] = ['/tmp/prestaflow/results.json'];
jest.mock('@actions/glob', () => ({
create: async () => ({
globGenerator: async function* () { yield '/tmp/prestaflow/results.json'; },
globGenerator: async function* () { for (const f of globFiles) yield f; },
}),
}));

const appendedNames: string[] = [];
jest.mock('form-data', () => {
return jest.fn().mockImplementation(() => ({
append: (field: string, _value: unknown, name?: string) => {
if (field === 'file[]') appendedNames.push(String(name));
},
getHeaders: () => ({ 'content-type': 'multipart/form-data' }),
}));
});

jest.mock('fs', () => {
const actual = jest.requireActual('fs');
return { ...actual, statSync: () => ({ isFile: () => true }), createReadStream: () => 'STREAM' };
Expand All @@ -15,15 +28,39 @@ jest.mock('fs', () => {
import { uploadToApi } from '../../src/upload/api';

describe('uploadToApi', () => {
beforeEach(() => {
globFiles = ['/tmp/prestaflow/results.json'];
appendedNames.length = 0;
postMock.mockClear();
});

it('posts with token header and returns id/url', async () => {
const r = await uploadToApi({ token: 't', projectId: '42' });
expect(r).toEqual({ id: 'r_42', url: 'https://app.prestaflow.io/reports/r_42' });
expect(postMock).toHaveBeenCalledWith(
'https://api.prestaflow.io/ci/github-action/',
'https://api.prestaflow.io/ci/github-action',
expect.anything(),
expect.objectContaining({
headers: expect.objectContaining({ 'X-Api-Token': 't' }),
}),
);
});

it('maps results.json and errors screenshots to unchanged names', async () => {
globFiles = [
'/tmp/prestaflow/results.json',
'/tmp/prestaflow/screens/errors/foo.png',
];
await uploadToApi({ token: 't', projectId: '42' });
expect(appendedNames).toEqual(['results.json', 'screens/foo.png']);
});

it('maps screens/actual and screens/diff to visual/actual and visual/diff', async () => {
globFiles = [
'/tmp/prestaflow/screens/actual/foo.png',
'/tmp/prestaflow/screens/diff/foo.png',
];
await uploadToApi({ token: 't', projectId: '42' });
expect(appendedNames).toEqual(['visual/actual/foo.png', 'visual/diff/foo.png']);
});
});
181 changes: 181 additions & 0 deletions __tests__/visual/prepare.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
const getMock = jest.fn();
jest.mock('axios', () => ({ __esModule: true, default: { get: (...a: unknown[]) => getMock(...a) } }));

const restoreCacheMock = jest.fn();
const saveCacheMock = jest.fn();
jest.mock('@actions/cache', () => ({
restoreCache: (...a: unknown[]) => restoreCacheMock(...a),
saveCache: (...a: unknown[]) => saveCacheMock(...a),
}));

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { prepareVisualBaselines } from '../../src/visual/prepare';

describe('prepareVisualBaselines', () => {
let tmpDir: string;

beforeEach(() => {
getMock.mockReset();
restoreCacheMock.mockReset();
saveCacheMock.mockReset();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'visual-prepare-'));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('is a no-op when disabled', async () => {
await prepareVisualBaselines({
apiBaseUrl: 'https://api.test',
token: 't',
projectId: 'p1',
enabled: false,
visualBaselineDir: tmpDir,
});
expect(getMock).not.toHaveBeenCalled();
});

it('warns and returns on 404', async () => {
getMock.mockResolvedValue({ status: 404, data: {} });
await expect(prepareVisualBaselines({
apiBaseUrl: 'https://api.test',
token: 't',
projectId: 'p1',
enabled: true,
visualBaselineDir: tmpDir,
})).resolves.toBeUndefined();
expect(restoreCacheMock).not.toHaveBeenCalled();
});

it('warns and returns on 501', async () => {
getMock.mockResolvedValue({ status: 501, data: {} });
await prepareVisualBaselines({
apiBaseUrl: 'https://api.test',
token: 't',
projectId: 'p1',
enabled: true,
visualBaselineDir: tmpDir,
});
expect(restoreCacheMock).not.toHaveBeenCalled();
});

it('throws an actionable error on other non-2xx', async () => {
getMock.mockResolvedValue({ status: 500, data: { message: 'boom' } });
await expect(prepareVisualBaselines({
apiBaseUrl: 'https://api.test',
token: 't',
projectId: 'p1',
enabled: true,
visualBaselineDir: tmpDir,
})).rejects.toThrow(/500/);
});

it('is a no-op when manifest has no baselines', async () => {
getMock.mockResolvedValue({ status: 200, data: { baselines: [], manifest_sha256: 'abc' } });
await prepareVisualBaselines({
apiBaseUrl: 'https://api.test',
token: 't',
projectId: 'p1',
enabled: true,
visualBaselineDir: tmpDir,
});
expect(restoreCacheMock).not.toHaveBeenCalled();
});

it('skips downloads on cache hit', async () => {
getMock.mockResolvedValueOnce({
status: 200,
data: {
baselines: [
{ id: '1', name: 'home', tag: 'auto-v9', sha256: 'abc', download_url: '/ci/visual/baselines/1' },
],
manifest_sha256: 'deadbeef',
},
});
restoreCacheMock.mockResolvedValue('visual-baseline-p1-deadbeef');

await prepareVisualBaselines({
apiBaseUrl: 'https://api.test',
token: 't',
projectId: 'p1',
enabled: true,
visualBaselineDir: tmpDir,
});

expect(restoreCacheMock).toHaveBeenCalledWith([tmpDir], 'visual-baseline-p1-deadbeef');
// only the manifest GET, no baseline-file GET
expect(getMock).toHaveBeenCalledTimes(1);
expect(saveCacheMock).not.toHaveBeenCalled();
});

it('happy path: downloads files, writes them, then saves cache', async () => {
const { Readable } = jest.requireActual('stream');

getMock
.mockResolvedValueOnce({
status: 200,
data: {
baselines: [
{ id: '1', name: 'home', tag: 'auto-v9', sha256: 'abc', download_url: '/ci/visual/baselines/1' },
{ id: '2', name: 'listing', tag: 'auto-v9', sha256: 'def', download_url: '/ci/visual/baselines/2' },
],
manifest_sha256: 'deadbeef',
},
})
.mockImplementation(async () => ({
status: 200,
data: Readable.from([Buffer.from('PNGDATA')]),
}));

restoreCacheMock.mockResolvedValue(undefined);
saveCacheMock.mockResolvedValue(1);

await prepareVisualBaselines({
apiBaseUrl: 'https://api.test',
token: 't',
projectId: 'p1',
branch: 'feature/x',
enabled: true,
visualBaselineDir: tmpDir,
});

expect(getMock).toHaveBeenCalledWith(
'https://api.test/ci/visual/baselines?branch=feature%2Fx',
expect.objectContaining({ headers: { 'X-Api-Token': 't' } }),
);
expect(fs.existsSync(path.join(tmpDir, 'home--auto-v9.png'))).toBe(true);
expect(fs.existsSync(path.join(tmpDir, 'listing--auto-v9.png'))).toBe(true);
expect(saveCacheMock).toHaveBeenCalledWith([tmpDir], 'visual-baseline-p1-deadbeef');
});

it('logs a warning and continues when one download fails', async () => {
getMock
.mockResolvedValueOnce({
status: 200,
data: {
baselines: [
{ id: '1', name: 'home', tag: 'auto-v9', sha256: 'abc', download_url: '/ci/visual/baselines/1' },
],
manifest_sha256: 'deadbeef',
},
})
.mockRejectedValueOnce(new Error('network down'));

restoreCacheMock.mockResolvedValue(undefined);
saveCacheMock.mockResolvedValue(1);

await expect(prepareVisualBaselines({
apiBaseUrl: 'https://api.test',
token: 't',
projectId: 'p1',
enabled: true,
visualBaselineDir: tmpDir,
})).resolves.toBeUndefined();

expect(fs.existsSync(path.join(tmpDir, 'home--auto-v9.png'))).toBe(false);
expect(saveCacheMock).toHaveBeenCalled();
});
});
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ inputs:
description: "Upload results.json + error screenshots as a GitHub artifact."
required: false
default: 'true'
visual:
description: "Enable visual regression round-trip with the API (download baselines before run, upload actual/diff after)."
required: false
default: 'true'

outputs:
id:
Expand Down
Loading
Loading