Skip to content
Draft
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
3 changes: 2 additions & 1 deletion .github/workflows/auto-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
with:
node-version: ${{ matrix.node }}
- run: npm install
- run: npx playwright install chromium
- run: npm run build
- run: npm run test-backend
env:
Expand Down Expand Up @@ -88,6 +89,6 @@ jobs:
with:
node-version: 20
- run: npm install
- run: npx playwright install
- run: npx playwright install chromium
- run: npm run build
- run: npm run test-e2e
119 changes: 93 additions & 26 deletions server/monitor-types/real-browser-monitor-type.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,37 +238,104 @@ class RealBrowserMonitorType extends MonitorType {
async check(monitor, heartbeat, server) {
const browser = monitor.remote_browser ? await getRemoteBrowser(monitor.remote_browser, monitor.user_id) : await getBrowser();
const context = await browser.newContext();
const page = await context.newPage();

// Prevent Local File Inclusion
// Accept only http:// and https://
// https://github.com/louislam/uptime-kuma/security/advisories/GHSA-2qgm-m29m-cj2h
let url = new URL(monitor.url);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Invalid url protocol, only http and https are allowed.");
}

const res = await page.goto(monitor.url, {
waitUntil: "networkidle",
timeout: monitor.interval * 1000 * 0.8,
});
let page;

let filename = jwt.sign(monitor.id, server.jwtSecret) + ".png";
try {
page = await context.newPage();

await page.screenshot({
path: path.join(Database.screenshotDir, filename),
});
// Prevent Local File Inclusion
// Accept only http://, https://, and data:// (for testing)
// https://github.com/louislam/uptime-kuma/security/advisories/GHSA-2qgm-m29m-cj2h
let url = new URL(monitor.url);
if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "data:") {
throw new Error("Invalid url protocol, only http, https and data are allowed.");
}

await context.close();
const res = await page.goto(monitor.url, {
waitUntil: "networkidle",
timeout: monitor.interval * 1000 * 0.8,
});

// Handle data: URLs which don't return a proper Response object
const isDataUrl = url.protocol === "data:";

// Normalize keyword early for reuse in checking and status messages
// This ensures consistent matching and reporting
let normalizedKeyword = null;

// Check for keyword if configured
if (monitor.keyword && monitor.keyword.trim()) {
// Normalize keyword the same way as page content to ensure consistent matching
// This prevents false negatives when users accidentally enter extra spaces in keywords
// For example, "Hello World" (double space) will match "Hello World" (single space) on page
normalizedKeyword = monitor.keyword.replace(/\s+/g, " ").trim();

// Extract all visible text content from the page
let textContent = await page.textContent("body");

if (textContent) {
// Normalize page content: replace duplicate white spaces with a single space
// This handles inconsistent spacing in HTML (tabs, newlines, multiple spaces, etc.)
textContent = textContent.replace(/\s+/g, " ").trim();

let keywordFound = textContent.includes(normalizedKeyword);
const invertKeyword = monitor.invertKeyword === true || monitor.invertKeyword === 1;

if (keywordFound === !invertKeyword) {
log.debug("monitor", `Keyword check passed. Keyword "${normalizedKeyword}" ${keywordFound ? "found" : "not found"} on page (invert: ${invertKeyword})`);
} else {
let errorText = textContent;
if (errorText.length > 50) {
errorText = errorText.substring(0, 47) + "...";
}

if (res.status() >= 200 && res.status() < 400) {
heartbeat.status = UP;
heartbeat.msg = res.status();
throw new Error(
`Keyword check failed. Keyword "${normalizedKeyword}" ${keywordFound ? "found" : "not found"} on page. ` +
`Expected: ${invertKeyword ? "not found" : "found"}. Page content: [${errorText}]`
);
}
} else {
throw new Error("Could not extract text content from page for keyword checking");
}
}

const timing = res.request().timing();
heartbeat.ping = timing.responseEnd;
} else {
throw new Error(res.status() + "");
let filename = jwt.sign(monitor.id, server.jwtSecret) + ".png";

await page.screenshot({
path: path.join(Database.screenshotDir, filename),
});

// Handle data: URLs vs HTTP/HTTPS URLs differently
if (isDataUrl || (res && res.status() >= 200 && res.status() < 400)) {
heartbeat.status = UP;
let statusMsg = isDataUrl ? "200" : res.status().toString();

// Add keyword info to message if keyword checking was performed
if (normalizedKeyword) {
const invertKeyword = monitor.invertKeyword === true || monitor.invertKeyword === 1;
statusMsg += `, keyword "${normalizedKeyword}" ${invertKeyword ? "not found" : "found"}`;
}

heartbeat.msg = statusMsg;

if (res && res.request()) {
const timing = res.request().timing();
heartbeat.ping = timing.responseEnd;
} else {
heartbeat.ping = 1; // Fallback timing
}
} else {
throw new Error(res ? res.status() + "" : "Network error");
}
} finally {
// Always close page and context, even if there was an error
// Close page first for proper cleanup order
if (page) {
await page.close();
}
if (context) {
await context.close();
}
}
}
}
Expand Down
16 changes: 12 additions & 4 deletions src/pages/EditMonitor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -156,16 +156,24 @@
</div>

<!-- Keyword -->
<div v-if="monitor.type === 'keyword' || monitor.type === 'grpc-keyword'" class="my-3">
<div v-if="monitor.type === 'keyword' || monitor.type === 'grpc-keyword' || monitor.type === 'real-browser'" class="my-3">
<label for="keyword" class="form-label">{{ $t("Keyword") }}</label>
<input id="keyword" v-model="monitor.keyword" type="text" class="form-control" required>
<input
id="keyword"
v-model="monitor.keyword"
type="text"
class="form-control"
:required="monitor.type === 'keyword' || monitor.type === 'grpc-keyword'"
>
<div class="form-text">
{{ $t("keywordDescription") }}
<span>
{{ $t("keywordDescription") }}
</span>
</div>
</div>

<!-- Invert keyword -->
<div v-if="monitor.type === 'keyword' || monitor.type === 'grpc-keyword'" class="my-3 form-check">
<div v-if="monitor.type === 'keyword' || monitor.type === 'grpc-keyword' || monitor.type === 'real-browser'" class="my-3 form-check">
<input id="invert-keyword" v-model="monitor.invertKeyword" class="form-check-input" type="checkbox">
<label class="form-check-label" for="invert-keyword">
{{ $t("Invert Keyword") }}
Expand Down
127 changes: 127 additions & 0 deletions test/backend-test/real-browser-test-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
const path = require("path");
const { UP, DOWN, PENDING } = require("../../src/util");

/**
* Real Browser Test Helper
* Common utilities for testing real browser monitor functionality
*/
class RealBrowserTestHelper {
/**
* Constructor for RealBrowserTestHelper
*/
constructor() {
this.RealBrowserMonitorType = null;
this.resetChrome = null;
this.originalModules = {};
}

/**
* Set up mocks for server dependencies
* @returns {void}
*/
setupMocks() {
const settingsPath = path.resolve(__dirname, "../../server/settings.js");
const databasePath = path.resolve(__dirname, "../../server/database.js");
const browserMonitorPath = path.resolve(__dirname, "../../server/monitor-types/real-browser-monitor-type.js");

// Store originals for cleanup
this.originalModules.settings = require.cache[settingsPath];
this.originalModules.database = require.cache[databasePath];
this.originalModules.browserMonitor = require.cache[browserMonitorPath];

delete require.cache[settingsPath];
delete require.cache[databasePath];
delete require.cache[browserMonitorPath];

require.cache[settingsPath] = {
exports: {
Settings: { get: async (key) => key === "chromeExecutable" ? "#playwright_chromium" : "default" }
}
};
require.cache[databasePath] = {
exports: { screenshotDir: "/tmp/uptime-kuma-test-screenshots" }
};

({ RealBrowserMonitorType: this.RealBrowserMonitorType, resetChrome: this.resetChrome } = require("../../server/monitor-types/real-browser-monitor-type"));
}

/**
* Clean up mocks and restore original modules
* @returns {void}
*/
cleanupMocks() {
const settingsPath = path.resolve(__dirname, "../../server/settings.js");
const databasePath = path.resolve(__dirname, "../../server/database.js");
const browserMonitorPath = path.resolve(__dirname, "../../server/monitor-types/real-browser-monitor-type.js");

// Remove our mocks
delete require.cache[settingsPath];
delete require.cache[databasePath];
delete require.cache[browserMonitorPath];

// Restore originals if they existed
if (this.originalModules.settings) {
require.cache[settingsPath] = this.originalModules.settings;
}
if (this.originalModules.database) {
require.cache[databasePath] = this.originalModules.database;
}
if (this.originalModules.browserMonitor) {
require.cache[browserMonitorPath] = this.originalModules.browserMonitor;
}
}

/**
* Run a monitor test and return the heartbeat result
* @param {object} monitor - The monitor configuration
* @returns {Promise<object>} The heartbeat object with test results
*/
async runMonitorTest(monitor) {
const monitorType = new this.RealBrowserMonitorType();
const heartbeat = {
msg: "",
status: PENDING,
ping: null
};
const server = { jwtSecret: "test-secret-key" };

try {
await monitorType.check(monitor, heartbeat, server);
return heartbeat;
} catch (error) {
heartbeat.status = DOWN;
heartbeat.msg = error.message;
throw error;
}
}

/**
* Set up test cleanup hooks
* @param {object} testSuite - The test suite object
* @returns {void}
*/
setupTestCleanup(testSuite) {
testSuite.after(async () => {
if (this.resetChrome) {
await this.resetChrome();
}
this.cleanupMocks();
});
}

/**
* Initialize the test helper with environment and mocks
* @returns {Promise<void>}
*/
async initialize() {
process.env.TEST_BACKEND = "1";
this.setupMocks();
}
}

module.exports = {
RealBrowserTestHelper,
UP,
DOWN,
PENDING
};
Loading
Loading