@applitools/eyes-playwright
Version:
Applitools Eyes SDK for Playwright
298 lines (297 loc) • 14.3 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.InternalData = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const req_1 = require("@applitools/req");
const utils = __importStar(require("@applitools/utils"));
const logger_1 = require("@applitools/logger");
const playwrightPath = require.resolve('playwright');
// Playwright moved the HtmlReporter from `lib/reporters/html.js` (≤1.59) into the bundled
// `lib/runner/index.js` (1.60+, exposed as `.html.default`). Both paths are private — not in
// `package.json#exports` — so resolve via absolute path (which bypasses exports gating). Try
// the 1.60+ location first, fall back to the pre-1.60 file. AD-13590.
const HtmlReporter = (() => {
const pwRoot = path.dirname(playwrightPath);
try {
return require(path.join(pwRoot, 'lib/runner/index.js')).html.default;
}
catch {
return require(path.join(pwRoot, 'lib/reporters/html')).default;
}
})();
const pluginsFile = require.resolve('../../dist/fixture/reportRenderer.js');
const styleFile = require.resolve('./reporterStyle.css');
const createInjectedScript = (testResultsMap) => `
<script>window.__icons = {
visualTest: \`${fs.readFileSync(require.resolve('./images/visual-text.svg'), 'utf-8')}\`,
link: \`${fs.readFileSync(require.resolve('./images/link.svg'), 'utf-8')}\`,
}</script>
<script>
${fs.readFileSync(pluginsFile, 'utf-8')}
window.__testResultsMap = ${JSON.stringify(testResultsMap)};
window.__initEyesReport();
</script>
<style type="text/css">${fs.readFileSync(styleFile, 'utf-8')}</style>
`;
class EyesReporter extends HtmlReporter {
constructor(options) {
super(options);
this.applitoolsIdentifiers = [];
// Detect Playwright UI mode so `onTestEnd` can avoid mutating the result: under
// `--ui`, the test server has already serialized the result and pushed it to the
// live UI client, so any splice into `attachments` / `_endPayload.attachments` /
// `steps[*].attachments` desyncs the trace viewer (Actions list / Source tab /
// Canvas disappear). Two signals are OR'd because no single one covers every
// supported Playwright version:
// 1. `options._isTestServer` — the official, propagated flag on Playwright
// 1.55–1.56. Upstream deleted it in 1.57 when the test runner and runner
// were merged, and did not replace it with any other propagated property.
// 2. A scan of `process.argv` for the CLI tokens that put Playwright into UI
// mode (`--ui`, `--ui-host`, `--ui-port`, and their `=value` forms). The
// reporter is constructed in the same Node process that parsed the CLI,
// and commander does not mutate `process.argv`, so the original token
// survives to this point on every version. This is a heuristic: the only
// known false-negative is a script that calls `runUIMode(...)` directly
// without going through `playwright test --ui`, which is not a documented
// entry point.
this._isUnderTestServer = Boolean(options === null || options === void 0 ? void 0 : options._isTestServer) || EyesReporter._argvIndicatesUiMode();
}
static _argvIndicatesUiMode() {
return process.argv.some(arg => arg === '--ui' ||
arg === '--ui-host' ||
arg === '--ui-port' ||
arg.startsWith('--ui=') ||
arg.startsWith('--ui-host=') ||
arg.startsWith('--ui-port='));
}
onTestEnd(test, result) {
var _a;
// Under Playwright UI mode (`--ui`), the test server has already serialized `result`
// (including `_endPayload.attachments`) and pushed it to the live UI client. Any
// mutation of attachments / steps after that point corrupts the trace viewer's
// model — Actions list, Source tab, and Canvas disappear after the test completes.
// Collect the Eyes identifier without mutating the result, and skip the upstream
// onTestEnd as well.
if (this._isUnderTestServer) {
const index = result.attachments.findIndex(a => a.name === 'applitoolsIdentifier');
if (index > -1) {
this.applitoolsIdentifiers.push(result.attachments[index].body.toString());
}
return;
}
(_a = super.onTestEnd) === null || _a === void 0 ? void 0 : _a.call(this, test, result);
const index = result.attachments.findIndex(a => a.name === 'applitoolsIdentifier');
if (index > -1) {
const applitoolsIdentifier = result.attachments[index].body.toString();
this.applitoolsIdentifiers.push(applitoolsIdentifier);
this.removeInternalIdAttachment(result);
}
}
// TODO: let's remove this method! Removing the internal id from the test result is wrong,
// it should be hidden in the HTML report and the test result should be read-only.
removeInternalIdAttachment(result) {
var _a, _b, _c;
const index = result.attachments ? result.attachments.findIndex(a => (a === null || a === void 0 ? void 0 : a.name) === 'applitoolsIdentifier') : -1;
if (index > -1)
(_a = result.attachments) === null || _a === void 0 ? void 0 : _a.splice(index, 1);
const index2 = result.attachments ? result.attachments.findIndex(a => !a) : -1;
if (index2 > -1)
(_c = (_b = result._endPayload) === null || _b === void 0 ? void 0 : _b.attachments) === null || _c === void 0 ? void 0 : _c.splice(index2, 1);
if (result.steps)
result.steps.forEach(step => this.removeInternalIdAttachment(step));
}
async onEnd(result) {
await super.onEnd(result);
const testResultsMap = {};
for (const applitoolsIdentifier of this.applitoolsIdentifiers) {
// Parse the identifier format: uuid|serverUrl|apiKey
const [uuid, serverUrl, apiKey] = applitoolsIdentifier.split('|');
if (uuid && serverUrl && apiKey) {
// Create a minimal eyes object for the consume call
const eyes = {
getServerUrl: () => serverUrl,
getApiKey: () => apiKey,
// No getLogger method - will use makeLogger() fallback
};
const content = await exports.InternalData.consume(uuid, eyes);
if (content) {
testResultsMap[content.key] = content.data;
}
}
}
try {
const filePath = path.join(this._outputFolder, 'index.html');
fs.appendFileSync(filePath, createInjectedScript(testResultsMap));
}
catch (e) {
// eslint-disable-next-line no-console
console.error('Error modifying index.html:', e);
}
}
}
exports.default = EyesReporter;
const getLogger = utils.general.cachify((settings) => {
return settings.logger || (0, logger_1.makeLogger)({ level: 'info' });
}, ([settings]) => settings.serverUrl + settings.apiKey);
const makeReporterRequest = (settings) => {
var _a;
const logger = getLogger(settings);
const req = (0, req_1.makeReq)({
expect: [200, 201],
baseUrl: (_a = settings.serverUrl) !== null && _a !== void 0 ? _a : 'https://eyes.applitools.com',
// API key header is set separately to avoid sending the key to Azure Blob Storage
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'x-applitools-eyes-client': 'eyes-playwright-reporter',
'User-Agent': 'eyes-playwright-reporter',
},
retry: [
{
limit: 3,
timeout: 200,
codes: [
'ECONNRESET',
'ECONNABORTED',
'ECONNREFUSED',
'ETIMEDOUT',
'ENOTFOUND',
'EAI_AGAIN',
'STUCK_REQUEST',
'ENOMEM',
'UND_ERR_SOCKET',
'UND_ERR_CONNECT_TIMEOUT',
'UND_ERR_HEADERS_TIMEOUT',
'UND_ERR_BODY_TIMEOUT',
],
},
],
hooks: {
beforeRequest: request => {
var _a;
logger.log(`Making request to ${request.request.url} with body: ${(_a = request.options) === null || _a === void 0 ? void 0 : _a.body}`);
},
afterResponse: async (response) => {
logger.log(`Received response from ${response.response.url} with status ${response.response.status} and body: ${await response.response.clone().text()}`);
if (response.response.status >= 400) {
logger.error(`Request to ${response.response.url} failed with status ${response.response.status}`, `and body: ${await response.response.clone().text()}`);
throw new Error(`Request to ${response.response.url} failed with status ${response.response.status}`);
}
},
},
});
return {
upload,
download,
};
async function upload(uuid, data) {
logger.log(`Uploading data for UUID: ${uuid}`);
const azureUrl = new URL(settings.uploadUrl);
try {
// Azure request headers - https://learn.microsoft.com/en-us/rest/api/storageservices/put-blob?tabs=microsoft-entra-id#request-headers-all-blob-types
// Required headers not explicitly set are added from query params in the uploadUrl - *important for authentication*
const azureResponse = await req(azureUrl, {
baseUrl: azureUrl.origin,
method: 'PUT',
headers: {
'x-ms-blob-type': 'BlockBlob',
'Content-Type': 'text/plain',
},
body: JSON.stringify(data),
});
const uploadResponse = {
respUpload: await azureResponse.text(),
};
logger.log('Data written successfully. Upload response:', uploadResponse);
return uploadResponse;
}
catch (error) {
logger.error(`Failed to upload data: ${error}`);
throw error;
}
}
async function download(uuid) {
const response = await req(`/batch/${uuid}/report/${uuid}`, {
method: 'GET',
headers: {
'X-Eyes-Api-Key': settings.apiKey,
},
});
return response.json();
}
};
exports.InternalData = {
async write({ testInfo, data, eyes, uuid, logger, }) {
var _a;
logger = logger.extend({ tags: [`internal-data-write-${utils.general.shortid()}`] });
if (!data || data.length === 0) {
logger.log(`No data to write for test: ${testInfo.title}`);
return;
}
const uploadUrl = eyes._eyes.test.account.batchExecReportsUrl
.replace('__batch_id__', uuid)
.replace('__report_id__', uuid);
const { upload } = makeReporterRequest({
serverUrl: (_a = eyes.getServerUrl()) !== null && _a !== void 0 ? _a : 'https://eyes.applitools.com',
apiKey: eyes.getApiKey(),
uploadUrl,
logger: logger,
});
await upload(uuid, {
key: `${testInfo.testId}--${testInfo.retry}`,
data,
});
},
async consume(uuid, eyes) {
var _a, _b, _c, _d;
const logger = (((_c = (_a = eyes.getLogger) === null || _a === void 0 ? void 0 : (_b = _a.call(eyes)).getLogger) === null || _c === void 0 ? void 0 : _c.call(_b)) || (0, logger_1.makeLogger)()).extend({
tags: [`internal-data-consume-${utils.general.shortid()}`],
});
const { download } = makeReporterRequest({
serverUrl: eyes.getServerUrl(),
apiKey: eyes.getApiKey(),
logger: logger,
});
for (let retry = 0; retry < 3; retry++) {
try {
const response = await download(uuid);
if (utils.types.has(response, 'status') && response.status !== 200) {
throw new Error(`Failed to consume data for UUID ${uuid}: ${response.status} - ${JSON.stringify(response)}`);
}
return { key: response.key, data: response.data };
}
catch (error) {
if ((_d = error === null || error === void 0 ? void 0 : error.message) === null || _d === void 0 ? void 0 : _d.includes('404')) {
logger.warn(`Data not found for UUID ${uuid} - skipping (no data was uploaded for this test)`);
return undefined;
}
logger.warn('Failed to consume data:', error, ". Perhaps the test doesn't include Applitools tests?");
}
await utils.general.sleep(1000 * (retry + 1)); // the backend might need a few seconds to process the upload
}
},
};