chore: import upstream snapshot with attribution
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Shared per-test fixture helpers for library / collection / news-subscription
|
||||
* UI tests.
|
||||
*
|
||||
* Seed and clean up a collection or a news subscription via the synchronous API
|
||||
* so tests don't depend on pre-existing DB state. These mirror the helpers proven in
|
||||
* test_crud_operations_ci.js (#4174/#4180/#4187); they live here now that a
|
||||
* second test file needs them. Each cleanup wraps page.evaluate
|
||||
* in a Node-side try/catch so a torn-down page during teardown can never throw
|
||||
* and mask a test result.
|
||||
*
|
||||
* All helpers read the CSRF token from the current page's meta tag, so the page
|
||||
* must already be on a same-origin app page before calling them.
|
||||
*/
|
||||
|
||||
async function seedCollection(page) {
|
||||
const r = await page.evaluate(async () => {
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
// Best-effort uniqueness (not guaranteed): timestamp + random suffix.
|
||||
const name = `ldr-ui-test-collection-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const res = await fetch('/library/api/collections', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf || '' },
|
||||
body: JSON.stringify({ name, description: 'UI test fixture', type: 'user_uploads' }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
return { ok: res.ok, name, success: body?.success === true, id: body?.collection?.id };
|
||||
});
|
||||
return r.ok && r.success && r.id ? { id: r.id, name: r.name } : null;
|
||||
}
|
||||
|
||||
async function deleteCollection(page, collectionId) {
|
||||
if (!collectionId) return;
|
||||
try {
|
||||
await page.evaluate(async (id) => {
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
try {
|
||||
await fetch(`/library/api/collections/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-CSRFToken': csrf || '' },
|
||||
});
|
||||
} catch { /* swallow fetch errors */ }
|
||||
}, collectionId);
|
||||
} catch { /* swallow page.evaluate errors so cleanup never masks the test result */ }
|
||||
}
|
||||
|
||||
async function seedSubscription(page, { isActive = true } = {}) {
|
||||
const r = await page.evaluate(async (active) => {
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
// Best-effort uniqueness (not guaranteed): timestamp + random suffix.
|
||||
const query = `ldr-ui-test-sub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const res = await fetch('/news/api/subscribe', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf || '' },
|
||||
body: JSON.stringify({ query, subscription_type: 'search', is_active: active }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
return { ok: res.ok, status: res.status, query, id: body?.subscription_id };
|
||||
}, isActive);
|
||||
return r.ok && r.id ? { id: r.id, query: r.query } : null;
|
||||
}
|
||||
|
||||
async function deleteSubscription(page, subscriptionId) {
|
||||
if (!subscriptionId) return;
|
||||
try {
|
||||
await page.evaluate(async (id) => {
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
try {
|
||||
await fetch(`/news/api/subscriptions/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-CSRFToken': csrf || '' },
|
||||
});
|
||||
} catch { /* swallow fetch errors */ }
|
||||
}, subscriptionId);
|
||||
} catch { /* swallow page.evaluate errors so cleanup never masks the test result */ }
|
||||
}
|
||||
|
||||
// NOTE: a document-upload helper lives in test_crud_operations_ci.js. It is
|
||||
// intentionally NOT exported here yet: the only consumer (documentCardActions)
|
||||
// is deferred until it can clean up the uploaded doc (collection delete doesn't
|
||||
// cascade to docs — see test_library_documents_ci.js). When that lands, add
|
||||
// uploadFixtureDocument here returning the document id(s) so callers can delete
|
||||
// the doc, not just the collection.
|
||||
|
||||
module.exports = { seedCollection, deleteCollection, seedSubscription, deleteSubscription };
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Test Infrastructure Library
|
||||
*
|
||||
* Single import point for all test utilities:
|
||||
*
|
||||
* const { setupTest, teardownTest, TestResults, log } = require('./test_lib');
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* const { setupTest, teardownTest, TestResults, log } = require('./test_lib');
|
||||
*
|
||||
* async function runTests() {
|
||||
* const ctx = await setupTest({ authenticate: true });
|
||||
* const results = new TestResults('My Test Suite');
|
||||
*
|
||||
* try {
|
||||
* log.section('Form Tests');
|
||||
*
|
||||
* await results.run('Forms', 'Query input exists', async () => {
|
||||
* await ctx.page.goto(`${ctx.config.baseUrl}/`);
|
||||
* const input = await ctx.page.$('#query');
|
||||
* if (!input) throw new Error('Query input not found');
|
||||
* }, { page: ctx.page });
|
||||
*
|
||||
* } finally {
|
||||
* results.print();
|
||||
* results.save();
|
||||
* await teardownTest(ctx);
|
||||
* process.exit(results.exitCode());
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* runTests();
|
||||
*/
|
||||
|
||||
const {
|
||||
config,
|
||||
viewports,
|
||||
setupTest,
|
||||
teardownTest,
|
||||
screenshot,
|
||||
delay,
|
||||
withTimeout,
|
||||
navigateTo,
|
||||
waitFor,
|
||||
waitForVisible,
|
||||
clickAndWaitForNavigation,
|
||||
getInputValue,
|
||||
clearAndType,
|
||||
findActionButton,
|
||||
log,
|
||||
} = require('./test_utils');
|
||||
|
||||
const { TestResults, escapeXml } = require('./test_results');
|
||||
|
||||
module.exports = {
|
||||
// Configuration
|
||||
config,
|
||||
viewports,
|
||||
|
||||
// Setup/Teardown
|
||||
setupTest,
|
||||
teardownTest,
|
||||
|
||||
// Utilities
|
||||
screenshot,
|
||||
delay,
|
||||
withTimeout,
|
||||
navigateTo,
|
||||
waitFor,
|
||||
waitForVisible,
|
||||
clickAndWaitForNavigation,
|
||||
getInputValue,
|
||||
clearAndType,
|
||||
findActionButton,
|
||||
|
||||
// Logging
|
||||
log,
|
||||
|
||||
// Results
|
||||
TestResults,
|
||||
escapeXml,
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Test Results Collector
|
||||
*
|
||||
* Single source of truth for test result tracking and output.
|
||||
* Supports JSON, JUnit XML, and console output formats.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Escape special characters for XML
|
||||
* @param {string} str - String to escape
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeXml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
class TestResults {
|
||||
/**
|
||||
* Create a new TestResults collector
|
||||
* @param {string} suiteName - Name of the test suite
|
||||
*/
|
||||
constructor(suiteName) {
|
||||
this.suiteName = suiteName;
|
||||
this.tests = [];
|
||||
this.startTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a test result
|
||||
* @param {string} category - Test category/group
|
||||
* @param {string} name - Test name
|
||||
* @param {boolean} passed - Whether the test passed
|
||||
* @param {string} [message=''] - Result message or error
|
||||
* @param {number} [duration=0] - Test duration in ms
|
||||
*/
|
||||
add(category, name, passed, message = '', duration = 0) {
|
||||
this.tests.push({
|
||||
category,
|
||||
name,
|
||||
passed,
|
||||
skipped: false,
|
||||
message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const icon = passed ? '\x1b[32m\u2713\x1b[0m' : '\x1b[31m\u2717\x1b[0m';
|
||||
console.log(` ${icon} [${category}] ${name}: ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a test as skipped
|
||||
* @param {string} category - Test category/group
|
||||
* @param {string} name - Test name
|
||||
* @param {string} [reason=''] - Reason for skipping
|
||||
*/
|
||||
skip(category, name, reason = '') {
|
||||
this.tests.push({
|
||||
category,
|
||||
name,
|
||||
passed: true,
|
||||
skipped: true,
|
||||
message: reason,
|
||||
duration: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.log(` \x1b[33m\u21B7\x1b[0m [${category}] ${name}: SKIPPED${reason ? ` - ${reason}` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a test function and record the result
|
||||
* @param {string} category - Test category
|
||||
* @param {string} name - Test name
|
||||
* @param {Function} testFn - Async test function
|
||||
* @param {Object} [context] - Optional context (page, etc.) for screenshots on failure
|
||||
* @returns {Promise<boolean>} Whether the test passed
|
||||
*/
|
||||
async run(category, name, testFn, context = {}) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await testFn();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Handle test functions that return { passed, message }
|
||||
if (result && typeof result === 'object' && 'passed' in result) {
|
||||
this.add(category, name, result.passed, result.message || '', duration);
|
||||
return result.passed;
|
||||
}
|
||||
|
||||
// Test passed if no error was thrown
|
||||
this.add(category, name, true, 'Passed', duration);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.add(category, name, false, error.message, duration);
|
||||
|
||||
// Take screenshot on failure if page is available
|
||||
if (context.page && context.screenshotOnFailure !== false) {
|
||||
try {
|
||||
const screenshotDir = path.join(__dirname, '..', 'screenshots');
|
||||
if (!fs.existsSync(screenshotDir)) {
|
||||
fs.mkdirSync(screenshotDir, { recursive: true });
|
||||
}
|
||||
const safeName = `${category}_${name}`.replace(/[^a-z0-9]/gi, '_');
|
||||
const screenshotPath = path.join(screenshotDir, `failure_${safeName}_${Date.now()}.png`);
|
||||
await context.page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
console.log(` \x1b[90mScreenshot saved: ${screenshotPath}\x1b[0m`);
|
||||
} catch (screenshotError) {
|
||||
console.log(` \x1b[90mFailed to capture screenshot: ${screenshotError.message}\x1b[0m`);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics
|
||||
* @returns {Object} Summary with total, passed, failed, skipped, duration
|
||||
*/
|
||||
get summary() {
|
||||
const total = this.tests.length;
|
||||
const skipped = this.tests.filter(t => t.skipped).length;
|
||||
const passed = this.tests.filter(t => t.passed && !t.skipped).length;
|
||||
const failed = total - passed - skipped;
|
||||
const duration = Date.now() - this.startTime;
|
||||
|
||||
return { total, passed, failed, skipped, duration };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get results as JSON object
|
||||
* @returns {Object}
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
suite: this.suiteName,
|
||||
summary: this.summary,
|
||||
tests: this.tests,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get results as JUnit XML
|
||||
* @returns {string}
|
||||
*/
|
||||
toJUnitXML() {
|
||||
const { summary } = this;
|
||||
|
||||
// Group tests by category
|
||||
const testsByCategory = {};
|
||||
for (const test of this.tests) {
|
||||
if (!testsByCategory[test.category]) {
|
||||
testsByCategory[test.category] = [];
|
||||
}
|
||||
testsByCategory[test.category].push(test);
|
||||
}
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
||||
xml += `<testsuites name="${escapeXml(this.suiteName)}" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${(summary.duration / 1000).toFixed(3)}">\n`;
|
||||
|
||||
for (const [category, tests] of Object.entries(testsByCategory)) {
|
||||
const catPassed = tests.filter(t => t.passed && !t.skipped).length;
|
||||
const catFailed = tests.filter(t => !t.passed).length;
|
||||
const catSkipped = tests.filter(t => t.skipped).length;
|
||||
const catDuration = tests.reduce((sum, t) => sum + (t.duration || 0), 0);
|
||||
|
||||
xml += ` <testsuite name="${escapeXml(category)}" tests="${tests.length}" failures="${catFailed}" skipped="${catSkipped}" time="${(catDuration / 1000).toFixed(3)}">\n`;
|
||||
|
||||
for (const test of tests) {
|
||||
xml += ` <testcase classname="${escapeXml(category)}" name="${escapeXml(test.name)}" time="${((test.duration || 0) / 1000).toFixed(3)}"`;
|
||||
|
||||
if (test.skipped) {
|
||||
xml += `>\n <skipped${test.message ? ` message="${escapeXml(test.message)}"` : ''}/>\n </testcase>\n`;
|
||||
} else if (!test.passed) {
|
||||
xml += `>\n <failure message="${escapeXml(test.message)}">${escapeXml(test.message)}</failure>\n </testcase>\n`;
|
||||
} else {
|
||||
xml += ` />\n`;
|
||||
}
|
||||
}
|
||||
|
||||
xml += ` </testsuite>\n`;
|
||||
}
|
||||
|
||||
xml += `</testsuites>\n`;
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print summary to console
|
||||
*/
|
||||
print() {
|
||||
const { summary } = this;
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(`TEST RESULTS: ${this.suiteName}`);
|
||||
console.log('='.repeat(60));
|
||||
console.log(`Total: ${summary.total} | Passed: \x1b[32m${summary.passed}\x1b[0m | Failed: \x1b[31m${summary.failed}\x1b[0m | Skipped: \x1b[33m${summary.skipped}\x1b[0m`);
|
||||
console.log(`Duration: ${(summary.duration / 1000).toFixed(2)}s`);
|
||||
console.log(`Pass Rate: ${summary.total > 0 ? ((summary.passed / (summary.total - summary.skipped)) * 100).toFixed(1) : 0}%`);
|
||||
|
||||
if (summary.failed > 0) {
|
||||
console.log('\n\x1b[31mFailed tests:\x1b[0m');
|
||||
this.tests
|
||||
.filter(t => !t.passed && !t.skipped)
|
||||
.forEach(t => {
|
||||
console.log(` \x1b[31m\u2717\x1b[0m [${t.category}] ${t.name}: ${t.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('='.repeat(60) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save results to file
|
||||
* @param {string} [dir] - Output directory
|
||||
* @param {Object} [options] - Save options
|
||||
* @param {boolean} [options.json=true] - Save JSON file
|
||||
* @param {boolean} [options.junit=true] - Save JUnit XML file
|
||||
*/
|
||||
save(dir, options = {}) {
|
||||
const outputDir = dir || path.join(__dirname, '..', 'test-results');
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const safeName = this.suiteName.replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
|
||||
if (options.json !== false) {
|
||||
const jsonPath = path.join(outputDir, `${safeName}.json`);
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(this.toJSON(), null, 2));
|
||||
console.log(`JSON results saved: ${jsonPath}`);
|
||||
}
|
||||
|
||||
if (options.junit !== false || process.env.CI) {
|
||||
const xmlPath = path.join(outputDir, `${safeName}.xml`);
|
||||
fs.writeFileSync(xmlPath, this.toJUnitXML());
|
||||
console.log(`JUnit XML saved: ${xmlPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate process exit code
|
||||
* @returns {number} 0 if all tests passed, 1 otherwise
|
||||
*/
|
||||
exitCode() {
|
||||
const { failed, passed, total } = this.summary;
|
||||
if (failed > 0) return 1;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TestResults, escapeXml };
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Shared Test Utilities
|
||||
*
|
||||
* Provides common functionality for UI tests:
|
||||
* - Browser setup/teardown
|
||||
* - Configuration management
|
||||
* - Helper functions (delay, waitFor, screenshot)
|
||||
* - Logging utilities
|
||||
*/
|
||||
|
||||
const puppeteer = require('puppeteer');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const AuthHelper = require('../auth_helper');
|
||||
const { getPuppeteerLaunchOptions } = require('../puppeteer_config');
|
||||
|
||||
// Centralized configuration - single source of truth
|
||||
const config = {
|
||||
baseUrl: process.env.BASE_URL || process.env.TEST_BASE_URL || 'http://127.0.0.1:5000',
|
||||
isCI: process.env.CI === 'true',
|
||||
headless: process.env.HEADLESS !== 'false',
|
||||
screenshotDir: path.join(__dirname, '..', 'screenshots'),
|
||||
resultsDir: path.join(__dirname, '..', 'test-results'),
|
||||
timeout: parseInt(process.env.TEST_TIMEOUT, 10) || 30000,
|
||||
slowMo: parseInt(process.env.SLOW_MO, 10) || 0,
|
||||
};
|
||||
|
||||
// Default viewport settings
|
||||
const viewports = {
|
||||
desktop: { width: 1280, height: 800 },
|
||||
mobile: { width: 375, height: 667, isMobile: true, hasTouch: true },
|
||||
tablet: { width: 768, height: 1024, isMobile: true, hasTouch: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize test environment with browser and optional authentication
|
||||
*
|
||||
* @param {Object} options - Setup options
|
||||
* @param {boolean} [options.authenticate=true] - Whether to authenticate the user
|
||||
* @param {boolean} [options.headless] - Override headless mode
|
||||
* @param {Object} [options.viewport] - Viewport settings (or use 'desktop', 'mobile', 'tablet')
|
||||
* @param {string} [options.username] - Custom username for authentication
|
||||
* @param {string} [options.password] - Custom password for authentication
|
||||
* @returns {Promise<Object>} Context object with browser, page, authHelper, config
|
||||
*/
|
||||
async function setupTest(options = {}) {
|
||||
const launchOptions = getPuppeteerLaunchOptions({
|
||||
headless: options.headless ?? config.headless,
|
||||
slowMo: options.slowMo ?? config.slowMo,
|
||||
});
|
||||
|
||||
const browser = await puppeteer.launch(launchOptions);
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Set viewport
|
||||
let viewport = options.viewport;
|
||||
if (typeof viewport === 'string' && viewports[viewport]) {
|
||||
viewport = viewports[viewport];
|
||||
}
|
||||
await page.setViewport(viewport || viewports.desktop);
|
||||
|
||||
// Set default timeouts to avoid indefinite hangs in CI
|
||||
const timeout = config.isCI ? 60000 : config.timeout;
|
||||
page.setDefaultTimeout(timeout);
|
||||
page.setDefaultNavigationTimeout(timeout);
|
||||
|
||||
// Create auth helper
|
||||
const authHelper = new AuthHelper(page, config.baseUrl);
|
||||
|
||||
// Authenticate if requested (default: true)
|
||||
if (options.authenticate !== false) {
|
||||
const authTimeoutMs = config.isCI ? 120000 : 30000;
|
||||
await withTimeout(
|
||||
authHelper.ensureAuthenticated(options.username, options.password),
|
||||
authTimeoutMs,
|
||||
'authentication'
|
||||
);
|
||||
}
|
||||
|
||||
// Return context object
|
||||
// Use authHelper.getPage() because ensureAuthenticated() may have created
|
||||
// a fresh page if the original one hit a detached frame error
|
||||
const finalPage = authHelper.getPage();
|
||||
finalPage.setDefaultTimeout(timeout);
|
||||
finalPage.setDefaultNavigationTimeout(timeout);
|
||||
|
||||
return {
|
||||
browser,
|
||||
page: finalPage,
|
||||
authHelper,
|
||||
config,
|
||||
viewports,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up test environment
|
||||
*
|
||||
* @param {Object} context - Context object from setupTest
|
||||
*/
|
||||
async function teardownTest(context) {
|
||||
if (context && context.browser) {
|
||||
await context.browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot with auto-directory creation
|
||||
*
|
||||
* @param {Object} page - Puppeteer page object
|
||||
* @param {string} name - Screenshot name (without extension)
|
||||
* @param {Object} [options] - Screenshot options
|
||||
* @param {boolean} [options.fullPage=true] - Capture full page
|
||||
* @returns {Promise<string>} Path to saved screenshot
|
||||
*/
|
||||
async function screenshot(page, name, options = {}) {
|
||||
const dir = config.screenshotDir;
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `${name}_${timestamp}.png`;
|
||||
const filepath = path.join(dir, filename);
|
||||
|
||||
await page.screenshot({
|
||||
path: filepath,
|
||||
fullPage: options.fullPage !== false,
|
||||
...options,
|
||||
});
|
||||
|
||||
return filepath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple delay function
|
||||
*
|
||||
* @param {number} ms - Milliseconds to wait
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Run a promise with a timeout. If the promise doesn't resolve within
|
||||
* the given time, it rejects with a TimeoutError. This prevents a single
|
||||
* hung sub-test from blocking the entire test suite until the 300s
|
||||
* process-level timeout kills it.
|
||||
*
|
||||
* @param {Promise} promise - The promise to race against the timeout
|
||||
* @param {number} ms - Timeout in milliseconds (default: 30s CI, 15s local)
|
||||
* @param {string} [label] - Description for the timeout error message
|
||||
* @returns {Promise} The original promise result or a timeout rejection
|
||||
*/
|
||||
function withTimeout(promise, ms, label = 'operation') {
|
||||
const timeoutMs = ms || (config.isCI ? 30000 : 15000);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`Timeout: ${label} did not complete within ${timeoutMs / 1000}s`));
|
||||
}, timeoutMs);
|
||||
promise.then(
|
||||
(val) => { clearTimeout(timer); resolve(val); },
|
||||
(err) => { clearTimeout(timer); reject(err); }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a page, skipping if already there.
|
||||
*
|
||||
* Many CI tests call page.goto() for the same URL in every test function.
|
||||
* Each navigation takes 5-15s in CI, so 15 tests × 10s = 150s+ of pure
|
||||
* navigation overhead. This helper skips the navigation when the page URL
|
||||
* already matches, cutting test suite time significantly.
|
||||
*
|
||||
* @param {Object} page - Puppeteer page object
|
||||
* @param {string} url - Full URL to navigate to
|
||||
* @param {Object} [options] - Navigation options
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function navigateTo(page, url, options = {}) {
|
||||
const currentUrl = page.url();
|
||||
// Check if already on the target page (compare path, ignore query/hash)
|
||||
const targetPath = new URL(url).pathname.replace(/\/$/, '');
|
||||
try {
|
||||
const currentPath = new URL(currentUrl).pathname.replace(/\/$/, '');
|
||||
if (currentPath === targetPath) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// about:blank or invalid URL — need to navigate
|
||||
}
|
||||
|
||||
const response = await page.goto(url, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: config.isCI ? 60000 : config.timeout,
|
||||
...options,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element with better error messages
|
||||
*
|
||||
* @param {Object} page - Puppeteer page object
|
||||
* @param {string} selector - CSS selector
|
||||
* @param {Object} [options] - Wait options
|
||||
* @param {number} [options.timeout] - Timeout in ms
|
||||
* @param {boolean} [options.visible] - Wait for visible element
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function waitFor(page, selector, options = {}) {
|
||||
const timeout = options.timeout || config.timeout;
|
||||
try {
|
||||
await page.waitForSelector(selector, {
|
||||
timeout,
|
||||
visible: options.visible,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
throw new Error(`Element "${selector}" not found after ${timeout}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for element to become visible (using waitForFunction for reliability)
|
||||
*
|
||||
* @param {Object} page - Puppeteer page object
|
||||
* @param {string} selector - CSS selector
|
||||
* @param {number} [timeout] - Timeout in ms
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function waitForVisible(page, selector, timeout = 5000) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden';
|
||||
},
|
||||
{ timeout },
|
||||
selector
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an element and wait for navigation
|
||||
*
|
||||
* @param {Object} page - Puppeteer page object
|
||||
* @param {string} selector - CSS selector to click
|
||||
* @param {Object} [options] - Navigation options
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function clickAndWaitForNavigation(page, selector, options = {}) {
|
||||
const waitUntil = options.waitUntil || 'domcontentloaded';
|
||||
const timeout = options.timeout || config.timeout;
|
||||
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil, timeout }),
|
||||
page.click(selector),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of an input element
|
||||
*
|
||||
* @param {Object} page - Puppeteer page object
|
||||
* @param {string} selector - CSS selector
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function getInputValue(page, selector) {
|
||||
return page.$eval(selector, el => el.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear and type into an input
|
||||
*
|
||||
* @param {Object} page - Puppeteer page object
|
||||
* @param {string} selector - CSS selector
|
||||
* @param {string} text - Text to type
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function clearAndType(page, selector, text) {
|
||||
await page.click(selector, { clickCount: 3 });
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.type(selector, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an action button on the current page by word-boundary keyword match
|
||||
* against its text content, and optionally click it.
|
||||
*
|
||||
* Word boundaries (`\b`) keep the match from firing on substrings — e.g.
|
||||
* the default keyword `new` matches "New Folder" but NOT "Back to News
|
||||
* Feed" (where "news" merely contains "new"). The original substring
|
||||
* matcher caused #4069.
|
||||
*
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.selectors] CSS selector list to scan.
|
||||
* Default: `'button, a.btn, .btn'`.
|
||||
* @param {string[]} [options.keywords] Lowercase words to match (combined
|
||||
* into `\b(?:k1|k2|...)\b`). Default: `['create', 'new', 'add']`.
|
||||
* @param {boolean} [options.click] If true, click the first match.
|
||||
* @returns {Promise<{found: boolean, text?: string}>}
|
||||
*/
|
||||
async function findActionButton(page, options = {}) {
|
||||
const {
|
||||
selectors = 'button, a.btn, .btn',
|
||||
keywords = ['create', 'new', 'add'],
|
||||
click = false,
|
||||
} = options;
|
||||
|
||||
return await page.evaluate((selectorsStr, keywordsArr, doClick) => {
|
||||
const pattern = new RegExp(`\\b(?:${keywordsArr.join('|')})\\b`);
|
||||
const buttons = Array.from(document.querySelectorAll(selectorsStr));
|
||||
const match = buttons.find(b => pattern.test((b.textContent || '').toLowerCase()));
|
||||
if (!match) return { found: false };
|
||||
if (doClick) match.click();
|
||||
return { found: true, text: match.textContent?.trim() };
|
||||
}, selectors, keywords, click);
|
||||
}
|
||||
|
||||
// Console logging with colors (works in terminal)
|
||||
const log = {
|
||||
info: (msg) => console.log(`\x1b[36m[INFO] ${msg}\x1b[0m`),
|
||||
success: (msg) => console.log(`\x1b[32m[PASS] ${msg}\x1b[0m`),
|
||||
error: (msg) => console.log(`\x1b[31m[FAIL] ${msg}\x1b[0m`),
|
||||
warning: (msg) => console.log(`\x1b[33m[WARN] ${msg}\x1b[0m`),
|
||||
section: (msg) => console.log(`\x1b[34m\n=== ${msg} ===\x1b[0m`),
|
||||
debug: (msg) => {
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`\x1b[90m[DEBUG] ${msg}\x1b[0m`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
config,
|
||||
viewports,
|
||||
setupTest,
|
||||
teardownTest,
|
||||
screenshot,
|
||||
delay,
|
||||
withTimeout,
|
||||
navigateTo,
|
||||
waitFor,
|
||||
waitForVisible,
|
||||
clickAndWaitForNavigation,
|
||||
getInputValue,
|
||||
clearAndType,
|
||||
findActionButton,
|
||||
log,
|
||||
};
|
||||
Reference in New Issue
Block a user