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
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,58 @@ Run [PrestaFlow](https://prestaflow.io) tests in your GitHub Actions workflows.
ps-version: '9.0.0'
```

### Module installation

When `composer.json` has `"type": "prestashop-module"`, the workspace is mounted in
`modules/<name>` and, once the shop is ready, the action runs
`php bin/console prestashop:module install <name>` in the container. If an init-script
already installed the module, PrestaShop upgrades it instead, which is harmless. A
failed install only emits a warning. Disable it with `flashlight-install-module: 'false'`.

### How the tests are configured with Flashlight

The PrestaFlow PHP library reads its settings from `$_ENV`, which phpdotenv fills from
**one** file: `.env.local` if it exists, otherwise `.env`. With `variables_order=GPCS`
(the production `php.ini`, used by `shivammathur/setup-php`), the process environment
never reaches `$_ENV`.

So when `flashlight: true`, the action writes a `.env.local` for the run, built from
(highest priority first):

1. every `PRESTAFLOW_*` variable set on the step with `env:` (works whatever `variables_order` is);
2. the values imposed by the Flashlight container: `PRESTAFLOW_FO_URL`,
`PRESTAFLOW_BO_URL` (`<fo>admin-dev/`) and `PRESTAFLOW_PS_VERSION`;
3. your own dotenv file, copied as is: `.env.local` if it exists, otherwise `.env`;
4. the Flashlight back-office credentials: `PRESTAFLOW_BO_EMAIL=admin@prestashop.com`,
`PRESTAFLOW_BO_PASSWD=prestashop`.

If your repository already has a `.env.local`, it is merged and put back at the end of
the run; otherwise the generated file is removed.

```yaml
- uses: PrestaFlow/github-action@v2
env:
PRESTAFLOW_LOCALE: fr
PRESTAFLOW_EXTRA_HEADERS: '{"X-Debug":"1"}'
with:
token: ${{ secrets.PRESTAFLOW_TOKEN }}
flashlight: true
ps-version: '8.1.7'
```

If your committed `.env` sets `PRESTAFLOW_BO_EMAIL` / `PRESTAFLOW_BO_PASSWD` for your
local shop, they win over the Flashlight defaults: remove them from `.env`, or set the
Flashlight ones with `env:` on the step.

## PR comment

When run in a `pull_request` workflow, the action posts (or updates) a comment on the PR with the run summary — a global pass/fail count, a per-suite table when there is more than one suite, and a collapsible list of up to 20 failed tests with a "+N more" footer for anything beyond.

There is one comment per project (`projectId`) and, with Flashlight, per `ps-version`:
in a matrix over PrestaShop versions each version keeps its own comment, updated on
every push. Comments posted by earlier `@v2` releases without a PS version in their
marker are no longer updated by Flashlight runs.

### Required workflow permissions

```yaml
Expand All @@ -43,7 +91,7 @@ Without `pull-requests: write` the action logs a warning and continues — your
with:
token: ${{ secrets.PRESTAFLOW_API_TOKEN }}
projectId: pk_01ABC...
comment: 'false'
pr-comment: 'false'
```

<!-- TODO: add screenshot of the PR comment -->
Expand Down
148 changes: 148 additions & 0 deletions __tests__/flashlight/dotenv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
encodeDotenvValue,
pickPrestaflowEnv,
renderFlashlightDotenv,
writeFlashlightDotenv,
FLASHLIGHT_BO_EMAIL,
FLASHLIGHT_BO_PASSWD,
} from '../../src/flashlight/dotenv';

// Minimal model of phpdotenv semantics for the lines we generate: one
// KEY=VALUE per line, later lines win (same-file keys are overwritable by
// phpdotenv's ImmutableWriter since they are not "externally defined").
function lastValues(body: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of body.split('\n')) {
const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)=(.*)$/.exec(line);
if (!m) continue;
out[m[1]] = m[2];
}
return out;
}

describe('encodeDotenvValue', () => {
it('single-quotes plain values (literal, no interpolation)', () => {
expect(encodeDotenvValue('http://localhost:8000/')).toBe(`'http://localhost:8000/'`);
expect(encodeDotenvValue('a#b $HOME ${X}')).toBe(`'a#b $HOME \${X}'`);
});

it('keeps JSON intact inside single quotes', () => {
const json = '[{"name":"a","value":"b"}]';
expect(encodeDotenvValue(json)).toBe(`'${json}'`);
});

it('falls back to double quotes with escapes when value has a single quote', () => {
expect(encodeDotenvValue(`it's "q" \\ $X`)).toBe(`"it's \\"q\\" \\\\ \\$X"`);
});

it('escapes newlines in double quotes', () => {
expect(encodeDotenvValue('l1\nl2')).toBe(`"l1\\nl2"`);
});

it('encodes empty string', () => {
expect(encodeDotenvValue('')).toBe(`''`);
});
});

describe('pickPrestaflowEnv', () => {
it('keeps only PRESTAFLOW_* keys with a defined value', () => {
expect(pickPrestaflowEnv({
PRESTAFLOW_BO_EMAIL: 'me@x',
PRESTAFLOW_EMPTY: '',
PATH: '/usr/bin',
INPUT_TOKEN: 't',
PRESTAFLOW_UNDEF: undefined,
})).toEqual({ PRESTAFLOW_BO_EMAIL: 'me@x', PRESTAFLOW_EMPTY: '' });
});
});

describe('renderFlashlightDotenv', () => {
const base = { foUrl: 'http://localhost:8000/', psVersion: '8.1.7' };

it('writes FO/BO URL, PS version and Flashlight BO credentials', () => {
const v = lastValues(renderFlashlightDotenv({ ...base, userContent: null, processEnv: {} }));
expect(v.PRESTAFLOW_FO_URL).toBe(`'http://localhost:8000/'`);
expect(v.PRESTAFLOW_BO_URL).toBe(`'http://localhost:8000/admin-dev/'`);
expect(v.PRESTAFLOW_PS_VERSION).toBe(`'8.1.7'`);
expect(v.PRESTAFLOW_BO_EMAIL).toBe(`'${FLASHLIGHT_BO_EMAIL}'`);
expect(v.PRESTAFLOW_BO_PASSWD).toBe(`'${FLASHLIGHT_BO_PASSWD}'`);
});

it('user file overrides Flashlight default credentials but not forced values', () => {
const userContent = [
'PRESTAFLOW_BO_EMAIL=me@shop.test',
'PRESTAFLOW_BO_PASSWD=secret',
'PRESTAFLOW_FO_URL=https://my-dev-shop.test/',
'PRESTAFLOW_LOCALE=fr',
].join('\n');
const v = lastValues(renderFlashlightDotenv({ ...base, userContent, processEnv: {} }));
expect(v.PRESTAFLOW_BO_EMAIL).toBe('me@shop.test');
expect(v.PRESTAFLOW_BO_PASSWD).toBe('secret');
expect(v.PRESTAFLOW_LOCALE).toBe('fr');
expect(v.PRESTAFLOW_FO_URL).toBe(`'http://localhost:8000/'`);
});

it('process env wins over everything, including forced values', () => {
const v = lastValues(renderFlashlightDotenv({
...base,
userContent: 'PRESTAFLOW_BO_EMAIL=me@shop.test',
processEnv: {
PRESTAFLOW_BO_EMAIL: 'env@x',
PRESTAFLOW_BO_URL: 'http://localhost:8000/admin123/',
PRESTAFLOW_EXTRA_HEADERS: '{"X-Test":"1"}',
},
}));
expect(v.PRESTAFLOW_BO_EMAIL).toBe(`'env@x'`);
expect(v.PRESTAFLOW_BO_URL).toBe(`'http://localhost:8000/admin123/'`);
expect(v.PRESTAFLOW_EXTRA_HEADERS).toBe(`'{"X-Test":"1"}'`);
});

it('keeps user content on its own lines even without trailing newline', () => {
const body = renderFlashlightDotenv({ ...base, userContent: 'PRESTAFLOW_LOCALE=fr', processEnv: {} });
expect(body).toMatch(/^PRESTAFLOW_LOCALE=fr$/m);
expect(body.endsWith('\n')).toBe(true);
});
});

describe('writeFlashlightDotenv', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pf-dotenv-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });

const params = () => ({ workspace: dir, foUrl: 'http://localhost:8000/', psVersion: '8.1.7', processEnv: {} });

it('merges the user .env and removes the generated .env.local on restore', () => {
fs.writeFileSync(path.join(dir, '.env'), 'PRESTAFLOW_BO_EMAIL=me@shop.test\n');
const h = writeFlashlightDotenv(params());
const body = fs.readFileSync(path.join(dir, '.env.local'), 'utf-8');
expect(lastValues(body).PRESTAFLOW_BO_EMAIL).toBe('me@shop.test');
expect(h.keys).toEqual(expect.arrayContaining([
'PRESTAFLOW_FO_URL', 'PRESTAFLOW_BO_URL', 'PRESTAFLOW_PS_VERSION',
'PRESTAFLOW_BO_EMAIL', 'PRESTAFLOW_BO_PASSWD',
]));
h.restore();
expect(fs.existsSync(path.join(dir, '.env.local'))).toBe(false);
expect(fs.readFileSync(path.join(dir, '.env'), 'utf-8')).toBe('PRESTAFLOW_BO_EMAIL=me@shop.test\n');
});

it('uses an existing .env.local as the user layer (phpdotenv ignores .env then) and restores it', () => {
fs.writeFileSync(path.join(dir, '.env'), 'PRESTAFLOW_LOCALE=de\n');
fs.writeFileSync(path.join(dir, '.env.local'), 'PRESTAFLOW_LOCALE=fr\n');
const h = writeFlashlightDotenv(params());
const v = lastValues(fs.readFileSync(path.join(dir, '.env.local'), 'utf-8'));
expect(v.PRESTAFLOW_LOCALE).toBe('fr');
expect(v.PRESTAFLOW_FO_URL).toBe(`'http://localhost:8000/'`);
h.restore();
expect(fs.readFileSync(path.join(dir, '.env.local'), 'utf-8')).toBe('PRESTAFLOW_LOCALE=fr\n');
});

it('reports process env keys so they can be stripped from the child env', () => {
const h = writeFlashlightDotenv({ ...params(), processEnv: { PRESTAFLOW_COOKIES: '[]', PATH: '/bin' } });
expect(h.keys).toContain('PRESTAFLOW_COOKIES');
expect(h.keys).not.toContain('PATH');
h.restore();
});
});
76 changes: 76 additions & 0 deletions __tests__/flashlight/install-module.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const execMock = jest.fn();
jest.mock('@actions/exec', () => ({ exec: (...a: unknown[]) => execMock(...a) }));

import * as core from '@actions/core';
import { moduleToInstall, installModule } from '../../src/flashlight/install-module';

type ExecOpts = { listeners?: { stdout?: (b: Buffer) => void; stderr?: (b: Buffer) => void } };

function execPrinting(stdout: string, code = 0) {
return async (_cmd: string, _args: string[], opts: ExecOpts) => {
opts.listeners?.stdout?.(Buffer.from(stdout));
return code;
};
}

describe('moduleToInstall', () => {
it('returns the module folder for a prestashop-module mounted under modules/', () => {
expect(moduleToInstall({
composerJson: { name: 'acme/mymodule', type: 'prestashop-module' },
containerPath: '/var/www/html/modules/mymodule',
})).toBe('mymodule');
});

it('returns null for themes, root mounts and unknown composer types', () => {
expect(moduleToInstall({ composerJson: { type: 'prestashop-theme' }, containerPath: '/var/www/html/themes/t' })).toBeNull();
expect(moduleToInstall({ composerJson: { type: 'prestashop-module' }, containerPath: '/var/www/html' })).toBeNull();
expect(moduleToInstall({ composerJson: null, containerPath: '/var/www/html/modules/repo' })).toBeNull();
expect(moduleToInstall({ composerJson: { type: 'project' }, containerPath: '/var/www/html/modules/repo' })).toBeNull();
});
});

describe('installModule', () => {
let warn: jest.SpyInstance;
beforeEach(() => {
execMock.mockReset();
warn = jest.spyOn(core, 'warning').mockImplementation(() => undefined);
jest.spyOn(core, 'info').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());

it('runs prestashop:module install inside the container', async () => {
execMock.mockImplementation(execPrinting(' Install action on module mymodule succeeded. \n'));
await expect(installModule({ composePath: '/tmp/c.yml', name: 'mymodule' })).resolves.toBe(true);
expect(execMock).toHaveBeenCalledWith(
'docker',
['compose', '-f', '/tmp/c.yml', 'exec', '-T', '-w', '/var/www/html', 'prestashop',
'php', 'bin/console', 'prestashop:module', 'install', 'mymodule'],
expect.objectContaining({ ignoreReturnCode: true }),
);
expect(warn).not.toHaveBeenCalled();
});

it('treats an already installed module as success (PrestaShop upgrades it, same message)', async () => {
execMock.mockImplementation(execPrinting('Install action on module mymodule succeeded.\n'));
await expect(installModule({ composePath: '/tmp/c.yml', name: 'mymodule' })).resolves.toBe(true);
expect(warn).not.toHaveBeenCalled();
});

it('warns without throwing when the install fails, even with exit code 0', async () => {
// PS 8.1 / 9.0 print the error and still exit 0.
execMock.mockImplementation(execPrinting('Cannot install module mymodule. The module is invalid and cannot be loaded.\n', 0));
await expect(installModule({ composePath: '/tmp/c.yml', name: 'mymodule' })).resolves.toBe(false);
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/mymodule.*flashlight-install-module/s));
});

it('warns without throwing when docker exec itself fails', async () => {
execMock.mockRejectedValue(new Error('no such service'));
await expect(installModule({ composePath: '/tmp/c.yml', name: 'mymodule' })).resolves.toBe(false);
expect(warn).toHaveBeenCalled();
});

it('refuses a module name that is not a plain folder name', async () => {
await expect(installModule({ composePath: '/tmp/c.yml', name: '../x' })).resolves.toBe(false);
expect(execMock).not.toHaveBeenCalled();
});
});
66 changes: 66 additions & 0 deletions __tests__/flashlight/readiness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const execMock = jest.fn().mockResolvedValue(0);
jest.mock('@actions/exec', () => ({ exec: (...a: unknown[]) => execMock(...a) }));

import * as http from 'http';
import { AddressInfo } from 'net';
import { waitForShop, startFlashlight } from '../../src/flashlight/docker';

// Fake shop: the front answers 200 right away (as Flashlight's front does
// while post-install scripts still run); /admin-dev/ answers 503 for the
// first `adminFailures` hits, then 302 to the login page.
function fakeShop(adminFailures: number): Promise<{ url: string; hits: string[]; close: () => Promise<void> }> {
const hits: string[] = [];
let left = adminFailures;
const server = http.createServer((req, res) => {
hits.push(req.url ?? '');
if (req.url === '/admin-dev/') {
if (left-- > 0) { res.statusCode = 503; res.end(); return; }
res.statusCode = 302;
res.setHeader('Location', '/admin-dev/index.php?controller=AdminLogin');
res.end();
return;
}
res.statusCode = 200; res.end('front');
});
return new Promise(resolve => server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as AddressInfo;
resolve({ url: `http://127.0.0.1:${port}`, hits, close: () => new Promise(r => server.close(() => r())) });
}));
}

describe('waitForShop', () => {
it('does not accept a 200 on / alone: waits for /admin-dev/ to redirect', async () => {
const shop = await fakeShop(2);
try {
await waitForShop(shop.url, { timeoutMs: 5000, intervalMs: 20 });
expect(shop.hits.filter(h => h === '/admin-dev/').length).toBe(3);
} finally { await shop.close(); }
});

it('times out when /admin-dev/ never answers 200/302', async () => {
const shop = await fakeShop(1_000_000);
try {
await expect(waitForShop(shop.url, { timeoutMs: 300, intervalMs: 20 }))
.rejects.toThrow(/not ready after 300ms.*admin-dev/);
} finally { await shop.close(); }
});
});

describe('startFlashlight readiness failure', () => {
beforeEach(() => execMock.mockClear());

it('prints docker compose logs and tears down before failing', async () => {
const shop = await fakeShop(1_000_000);
const port = Number(new URL(shop.url).port);
try {
await expect(startFlashlight({
composeYaml: 'services: {}\n', port, host: '127.0.0.1', readyTimeoutMs: 200, pollIntervalMs: 20,
})).rejects.toThrow(/not ready/);
} finally { await shop.close(); }
const calls = execMock.mock.calls.map(c => (c[1] as string[]).join(' '));
const logsIdx = calls.findIndex(c => /compose -f .* logs/.test(c));
const downIdx = calls.findIndex(c => /compose -f .* down -v/.test(c));
expect(logsIdx).toBeGreaterThan(-1);
expect(downIdx).toBeGreaterThan(logsIdx);
});
});
10 changes: 10 additions & 0 deletions __tests__/inputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ describe('parseInputs', () => {
expect(i.prComment).toBe(false);
expect(i.uploadArtifacts).toBe(true);
expect(i.visual).toBe(true);
expect(i.flashlightInstallModule).toBe(true);
});
});

it('parses flashlight-install-module input', () => {
withInputs({ token: 't', 'flashlight-install-module': 'false' }, 'push', () => {
expect(parseInputs().flashlightInstallModule).toBe(false);
});
withInputs({ token: 't', 'flashlight-install-module': '' }, 'push', () => {
expect(parseInputs().flashlightInstallModule).toBe(true);
});
});

Expand Down
Loading
Loading