Reporter: Add framework for automatic testing of Discord updates (#3208)

This commit is contained in:
v 2025-02-09 01:46:08 +01:00 committed by GitHub
parent e8639e2e16
commit 5d482ff3bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 156 additions and 96 deletions

View file

@ -1,9 +1,22 @@
name: Test Patches name: Test Patches
on: on:
workflow_dispatch: workflow_dispatch:
schedule: inputs:
# Every day at midnight discord_branch:
- cron: 0 0 * * * type: choice
description: "Discord Branch to test patches on"
options:
- both
- stable
- canary
default: both
webhook_url:
type: string
description: "Webhook URL that the report will be posted to. This will be visible for everyone, so DO NOT pass sensitive webhooks like discord webhook. This is meant to be used by Venbot."
required: false
# schedule:
# # Every day at midnight
# - cron: 0 0 * * *
jobs: jobs:
TestPlugins: TestPlugins:
@ -40,28 +53,43 @@ jobs:
- name: Build Vencord Reporter Version - name: Build Vencord Reporter Version
run: pnpm buildReporter run: pnpm buildReporter
- name: Create Report - name: Run Reporter
timeout-minutes: 10 timeout-minutes: 10
run: | run: |
export PATH="$PWD/node_modules/.bin:$PATH" export PATH="$PWD/node_modules/.bin:$PATH"
export CHROMIUM_BIN=${{ steps.setup-chrome.outputs.chrome-path }} export CHROMIUM_BIN=${{ steps.setup-chrome.outputs.chrome-path }}
esbuild scripts/generateReport.ts > dist/report.mjs esbuild scripts/generateReport.ts > dist/report.mjs
node dist/report.mjs >> $GITHUB_STEP_SUMMARY
env:
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
- name: Create Report (Canary) stable_output_file=$(mktemp)
timeout-minutes: 10 canary_output_file=$(mktemp)
if: success() || failure() # even run if previous one failed
run: |
export PATH="$PWD/node_modules/.bin:$PATH"
export CHROMIUM_BIN=${{ steps.setup-chrome.outputs.chrome-path }}
export USE_CANARY=true
esbuild scripts/generateReport.ts > dist/report.mjs pids=""
node dist/report.mjs >> $GITHUB_STEP_SUMMARY
branch="${{ inputs.discord_branch }}"
if [[ "${{ github.event_name }}" = "schedule" ]]; then
branch="both"
fi
if [[ "$branch" = "both" || "$branch" = "stable" ]]; then
node dist/report.mjs > "$stable_output_file" &
pids+=" $!"
fi
if [[ "$branch" = "both" || "$branch" = "canary" ]]; then
USE_CANARY=true node dist/report.mjs > "$canary_output_file" &
pids+=" $!"
fi
exit_code=0
for pid in $pids; do
if ! wait "$pid"; then
exit_code=1
fi
done
cat "$stable_output_file" "$canary_output_file" >> $GITHUB_STEP_SUMMARY
exit $exit_code
env: env:
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }} WEBHOOK_URL: ${{ inputs.webhook_url || secrets.DISCORD_WEBHOOK }}
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}

View file

@ -23,17 +23,24 @@
// eslint-disable-next-line spaced-comment // eslint-disable-next-line spaced-comment
/// <reference types="../src/modules" /> /// <reference types="../src/modules" />
import { createHmac } from "crypto";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import pup, { JSHandle } from "puppeteer-core"; import pup, { JSHandle } from "puppeteer-core";
for (const variable of ["DISCORD_TOKEN", "CHROMIUM_BIN"]) { const logStderr = (...data: any[]) => console.error(`${CANARY ? "CANARY" : "STABLE"} ---`, ...data);
for (const variable of ["CHROMIUM_BIN"]) {
if (!process.env[variable]) { if (!process.env[variable]) {
console.error(`Missing environment variable ${variable}`); logStderr(`Missing environment variable ${variable}`);
process.exit(1); process.exit(1);
} }
} }
const CANARY = process.env.USE_CANARY === "true"; const CANARY = process.env.USE_CANARY === "true";
let metaData = {
buildNumber: "Unknown Build Number",
buildHash: "Unknown Build Hash"
};
const browser = await pup.launch({ const browser = await pup.launch({
headless: true, headless: true,
@ -128,16 +135,18 @@ async function printReport() {
console.log(); console.log();
if (process.env.DISCORD_WEBHOOK) { if (process.env.WEBHOOK_URL) {
await fetch(process.env.DISCORD_WEBHOOK, { const body = JSON.stringify({
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
description: "Here's the latest Vencord Report!",
username: "Vencord Reporter" + (CANARY ? " (Canary)" : ""), username: "Vencord Reporter" + (CANARY ? " (Canary)" : ""),
embeds: [ embeds: [
{
author: {
name: `Discord ${CANARY ? "Canary" : "Stable"} (${metaData.buildNumber})`,
url: `https://nelly.tools/builds/app/${metaData.buildHash}`,
icon_url: CANARY ? "https://cdn.discordapp.com/emojis/1252721945699549327.png?size=128" : "https://cdn.discordapp.com/emojis/1252721943463985272.png?size=128"
},
color: CANARY ? 0xfbb642 : 0x5865f2
},
{ {
title: "Bad Patches", title: "Bad Patches",
description: report.badPatches.map(p => { description: report.badPatches.map(p => {
@ -174,10 +183,26 @@ async function printReport() {
color: report.otherErrors.length ? 0xff0000 : 0x00ff00 color: report.otherErrors.length ? 0xff0000 : 0x00ff00
} }
] ]
}) });
const headers = {
"Content-Type": "application/json"
};
// functions similar to https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
// used by venbot to ensure webhook invocations are genuine (since we will pass the webhook url as a workflow input which is publicly visible)
// generate a secret with something like `openssl rand -hex 128`
if (process.env.WEBHOOK_SECRET) {
headers["X-Signature"] = "sha256=" + createHmac("sha256", process.env.WEBHOOK_SECRET).update(body).digest("hex");
}
await fetch(process.env.WEBHOOK_URL, {
method: "POST",
headers,
body
}).then(res => { }).then(res => {
if (!res.ok) console.error(`Webhook failed with status ${res.status}`); if (!res.ok) logStderr(`Webhook failed with status ${res.status}`);
else console.error("Posted to Discord Webhook successfully"); else logStderr("Posted to Webhook successfully");
}); });
} }
} }
@ -186,10 +211,13 @@ page.on("console", async e => {
const level = e.type(); const level = e.type();
const rawArgs = e.args(); const rawArgs = e.args();
async function getText() { async function getText(skipFirst = true) {
let args = e.args();
if (skipFirst) args = args.slice(1);
try { try {
return await Promise.all( return await Promise.all(
e.args().map(async a => { args.map(async a => {
return await maybeGetError(a) || await a.jsonValue(); return await maybeGetError(a) || await a.jsonValue();
}) })
).then(a => a.join(" ").trim()); ).then(a => a.join(" ").trim());
@ -202,6 +230,12 @@ page.on("console", async e => {
const isVencord = firstArg === "[Vencord]"; const isVencord = firstArg === "[Vencord]";
const isDebug = firstArg === "[PUP_DEBUG]"; const isDebug = firstArg === "[PUP_DEBUG]";
const isReporterMeta = firstArg === "[REPORTER_META]";
if (isReporterMeta) {
metaData = await rawArgs[1].jsonValue() as any;
return;
}
outer: outer:
if (isVencord) { if (isVencord) {
@ -218,7 +252,7 @@ page.on("console", async e => {
const patchFailMatch = message.match(/Patch by (.+?) (had no effect|errored|found no module|took [\d.]+?ms) \(Module id is (.+?)\): (.+)/)!; const patchFailMatch = message.match(/Patch by (.+?) (had no effect|errored|found no module|took [\d.]+?ms) \(Module id is (.+?)\): (.+)/)!;
if (!patchFailMatch) break; if (!patchFailMatch) break;
console.error(await getText()); logStderr(await getText());
process.exitCode = 1; process.exitCode = 1;
const [, plugin, type, id, regex] = patchFailMatch; const [, plugin, type, id, regex] = patchFailMatch;
@ -235,7 +269,7 @@ page.on("console", async e => {
const failedToStartMatch = message.match(/Failed to start (.+)/); const failedToStartMatch = message.match(/Failed to start (.+)/);
if (!failedToStartMatch) break; if (!failedToStartMatch) break;
console.error(await getText()); logStderr(await getText());
process.exitCode = 1; process.exitCode = 1;
const [, name] = failedToStartMatch; const [, name] = failedToStartMatch;
@ -246,7 +280,7 @@ page.on("console", async e => {
break; break;
case "LazyChunkLoader:": case "LazyChunkLoader:":
console.error(await getText()); logStderr(await getText());
switch (message) { switch (message) {
case "A fatal error occurred:": case "A fatal error occurred:":
@ -255,7 +289,7 @@ page.on("console", async e => {
break; break;
case "Reporter:": case "Reporter:":
console.error(await getText()); logStderr(await getText());
switch (message) { switch (message) {
case "A fatal error occurred:": case "A fatal error occurred:":
@ -273,47 +307,36 @@ page.on("console", async e => {
} }
if (isDebug) { if (isDebug) {
console.error(await getText()); logStderr(await getText());
} else if (level === "error") { } else if (level === "error") {
const text = await getText(); const text = await getText(false);
if (text.length && !text.startsWith("Failed to load resource: the server responded with a status of") && !text.includes("Webpack")) { if (text.length && !text.startsWith("Failed to load resource: the server responded with a status of") && !text.includes("Webpack")) {
if (IGNORED_DISCORD_ERRORS.some(regex => text.match(regex))) { if (IGNORED_DISCORD_ERRORS.some(regex => text.match(regex))) {
report.ignoredErrors.push(text); report.ignoredErrors.push(text);
} else { } else {
console.error("[Unexpected Error]", text); logStderr("[Unexpected Error]", text);
report.otherErrors.push(text); report.otherErrors.push(text);
} }
} }
} }
}); });
page.on("error", e => console.error("[Error]", e.message)); page.on("error", e => logStderr("[Error]", e.message));
page.on("pageerror", e => { page.on("pageerror", e => {
if (e.message.includes("Sentry successfully disabled")) return; if (e.message.includes("Sentry successfully disabled")) return;
if (!e.message.startsWith("Object") && !e.message.includes("Cannot find module") && !/^.{1,2}$/.test(e.message)) { if (!e.message.startsWith("Object") && !e.message.includes("Cannot find module") && !/^.{1,2}$/.test(e.message)) {
console.error("[Page Error]", e.message); logStderr("[Page Error]", e.message);
report.otherErrors.push(e.message); report.otherErrors.push(e.message);
} else { } else {
report.ignoredErrors.push(e.message); report.ignoredErrors.push(e.message);
} }
}); });
async function reporterRuntime(token: string) {
Vencord.Webpack.waitFor(
"loginToken",
m => {
console.log("[PUP_DEBUG]", "Logging in with token...");
m.loginToken(token);
}
);
}
await page.evaluateOnNewDocument(` await page.evaluateOnNewDocument(`
if (location.host.endsWith("discord.com")) { if (location.host.endsWith("discord.com")) {
${readFileSync("./dist/browser.js", "utf-8")}; ${readFileSync("./dist/browser.js", "utf-8")};
(${reporterRuntime.toString()})(${JSON.stringify(process.env.DISCORD_TOKEN)});
} }
`); `);

View file

@ -7,6 +7,7 @@
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import * as Webpack from "@webpack"; import * as Webpack from "@webpack";
import { addPatch, patches } from "plugins"; import { addPatch, patches } from "plugins";
import { getBuildNumber } from "webpack/patchWebpack";
import { loadLazyChunks } from "./loadLazyChunks"; import { loadLazyChunks } from "./loadLazyChunks";
@ -37,6 +38,13 @@ async function runReporter() {
await loadLazyChunksDone; await loadLazyChunksDone;
if (IS_REPORTER && IS_WEB && !IS_VESKTOP) {
console.log("[REPORTER_META]", {
buildNumber: getBuildNumber(),
buildHash: window.GLOBAL_ENV.SENTRY_TAGS.buildId
});
}
for (const patch of patches) { for (const patch of patches) {
if (!patch.all) { if (!patch.all) {
new Logger("WebpackInterceptor").warn(`Patch by ${patch.plugin} found no module (Module id is -): ${patch.find}`); new Logger("WebpackInterceptor").warn(`Patch by ${patch.plugin} found no module (Module id is -): ${patch.find}`);
@ -44,7 +52,7 @@ async function runReporter() {
} }
for (const [plugin, moduleId, match, totalTime] of Vencord.WebpackPatcher.patchTimings) { for (const [plugin, moduleId, match, totalTime] of Vencord.WebpackPatcher.patchTimings) {
if (totalTime > 3) { if (totalTime > 5) {
new Logger("WebpackInterceptor").warn(`Patch by ${plugin} took ${Math.round(totalTime * 100) / 100}ms (Module id is ${String(moduleId)}): ${match}`); new Logger("WebpackInterceptor").warn(`Patch by ${plugin} took ${Math.round(totalTime * 100) / 100}ms (Module id is ${String(moduleId)}): ${match}`);
} }
} }

View file

@ -32,7 +32,7 @@ export class Logger {
constructor(public name: string, public color: string = "white") { } constructor(public name: string, public color: string = "white") { }
private _log(level: "log" | "error" | "warn" | "info" | "debug", levelColor: string, args: any[], customFmt = "") { private _log(level: "log" | "error" | "warn" | "info" | "debug", levelColor: string, args: any[], customFmt = "") {
if (IS_REPORTER && IS_WEB) { if (IS_REPORTER && IS_WEB && !IS_VESKTOP) {
console[level]("[Vencord]", this.name + ":", ...args); console[level]("[Vencord]", this.name + ":", ...args);
return; return;
} }

View file

@ -442,11 +442,12 @@ function patchFactory(id: PropertyKey, factory: AnyModuleFactory): [patchedFacto
continue; continue;
} }
const buildNumber = getBuildNumber(); // Reporter eagerly patches and cannot retrieve the build number because this code runs before the module for it is loaded
const shouldCheckBuildId = !Settings.eagerPatches && buildNumber !== -1; const buildNumber = IS_REPORTER ? -1 : getBuildNumber();
const shouldCheckBuildNumber = !Settings.eagerPatches && buildNumber !== -1;
if ( if (
shouldCheckBuildId && shouldCheckBuildNumber &&
(patch.fromBuild != null && buildNumber < patch.fromBuild) || (patch.fromBuild != null && buildNumber < patch.fromBuild) ||
(patch.toBuild != null && buildNumber > patch.toBuild) (patch.toBuild != null && buildNumber > patch.toBuild)
) { ) {
@ -468,7 +469,7 @@ function patchFactory(id: PropertyKey, factory: AnyModuleFactory): [patchedFacto
// We change all patch.replacement to array in plugins/index // We change all patch.replacement to array in plugins/index
for (const replacement of patch.replacement as PatchReplacement[]) { for (const replacement of patch.replacement as PatchReplacement[]) {
if ( if (
shouldCheckBuildId && shouldCheckBuildNumber &&
(replacement.fromBuild != null && buildNumber < replacement.fromBuild) || (replacement.fromBuild != null && buildNumber < replacement.fromBuild) ||
(replacement.toBuild != null && buildNumber > replacement.toBuild) (replacement.toBuild != null && buildNumber > replacement.toBuild)
) { ) {