mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-02-03 21:09:37 +00:00
`Flaky test create_and_destroy: /obj/item/organ/internal/ears hard deleted 1 times out of a total del count of 495` -> `Flaky hard delete: /obj/item/organ/internal/ears` `Flaky test monkey_business: Invalid timer: Timer: Timer: 2433 ([0x2100859b]), TTR: 328041, wait:2 Flags: TIMER_CLIENT_TIME, TIMER_STOPPABLE, callBack: [0x2105a831], callBack.object: /datum/looping_sound/showering[0x210085b4](/datum/looping_sound/showering), callBack.delegate:/datum/looping_sound/proc/start_sound_loop(), source: code/datums/looping_sounds/_looping_sound.dm:220Prev: NULL, Next: NULL, SPENT(328041), QDELETED, NO CALLBACK world.time: 942.5, head_offset: 600, practical_offset: 686, REALTIMEOFDAY: 328041` -> `Flaky test monkey_business: Invalid timer: /datum/looping_sound/proc/start_sound_loop() on /datum/looping_sound/showering` I will close every flaky test report with these patterns so that they are repopulated with the new deduplicated names.
294 lines
7.4 KiB
JavaScript
294 lines
7.4 KiB
JavaScript
const LABEL = "🤖 Flaky Test Report";
|
|
const TITLE_BOT_HEADER = "title: ";
|
|
|
|
// Only check jobs that start with these.
|
|
// Helps make sure we don't restart something like screenshot tests or linters, which are not known to be flaky.
|
|
const CONSIDERED_JOBS = [
|
|
"Integration Tests",
|
|
];
|
|
|
|
async function getFailedJobsForRun(github, context, workflowRunId, runAttempt) {
|
|
const {
|
|
data: { jobs },
|
|
} = await github.rest.actions.listJobsForWorkflowRunAttempt({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
run_id: workflowRunId,
|
|
attempt_number: runAttempt,
|
|
});
|
|
|
|
return jobs
|
|
.filter((job) => job.conclusion === "failure")
|
|
.filter((job) =>
|
|
CONSIDERED_JOBS.some((title) => job.name.startsWith(title))
|
|
);
|
|
}
|
|
|
|
export async function rerunFlakyTests({ github, context }) {
|
|
const failingJobs = await getFailedJobsForRun(
|
|
github,
|
|
context,
|
|
context.payload.workflow_run.id,
|
|
context.payload.workflow_run.run_attempt
|
|
);
|
|
|
|
if (failingJobs.length > 1) {
|
|
console.log("Multiple jobs failing. PROBABLY not flaky, not rerunning.");
|
|
return;
|
|
}
|
|
|
|
if (failingJobs.length === 0) {
|
|
throw new Error(
|
|
"rerunFlakyTests should not have run on a run with no failing jobs"
|
|
);
|
|
}
|
|
|
|
github.rest.actions.reRunWorkflowFailedJobs({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
run_id: context.payload.workflow_run.id,
|
|
});
|
|
}
|
|
|
|
// Tries its best to extract a useful error title and message for the given log
|
|
export function extractDetails(log) {
|
|
// Strip off timestamp
|
|
const lines = log.split(/^[0-9.:T\-]*?Z /gm);
|
|
|
|
const failureRegex = /^\t?FAILURE #(?<number>[0-9]+): (?<headline>.+)/;
|
|
const groupRegex = /^##\[group\](?<group>.+)/;
|
|
|
|
const failures = [];
|
|
let lastGroup = "root";
|
|
let loggingFailure;
|
|
|
|
const newFailure = (failureMatch) => {
|
|
const { headline } = failureMatch.groups;
|
|
|
|
loggingFailure = {
|
|
headline,
|
|
group: lastGroup.replace("/datum/unit_test/", ""),
|
|
details: [],
|
|
};
|
|
};
|
|
|
|
for (const line of lines) {
|
|
const groupMatch = line.match(groupRegex);
|
|
if (groupMatch) {
|
|
lastGroup = groupMatch.groups.group.trim();
|
|
continue;
|
|
}
|
|
|
|
const failureMatch = line.match(failureRegex);
|
|
|
|
if (loggingFailure === undefined) {
|
|
if (!failureMatch) {
|
|
continue;
|
|
}
|
|
|
|
newFailure(failureMatch);
|
|
} else if (failureMatch || line.startsWith("##")) {
|
|
failures.push(loggingFailure);
|
|
loggingFailure = undefined;
|
|
|
|
if (failureMatch) {
|
|
newFailure(failureMatch);
|
|
}
|
|
} else {
|
|
loggingFailure.details.push(line.trim());
|
|
}
|
|
}
|
|
|
|
// We had no logged failures, there's not really anything we can do here
|
|
if (failures.length === 0) {
|
|
return {
|
|
title: "Flaky test failure with no obvious source",
|
|
failures,
|
|
};
|
|
}
|
|
|
|
// We *could* create multiple failures for multiple groups.
|
|
// This would be important if we had multiple flaky tests at the same time.
|
|
// I'm choosing not to because it complicates this logic a bit, has the ability to go terribly wrong,
|
|
// and also because there's something funny to me about that increasing the urgency of fixing
|
|
// flaky tests. If it becomes a serious issue though, I would not mind this being fixed.
|
|
const uniqueGroups = new Set(failures.map((failure) => failure.group));
|
|
|
|
if (uniqueGroups.size > 1) {
|
|
return {
|
|
title: `Multiple flaky test failures in ${Array.from(uniqueGroups)
|
|
.sort()
|
|
.join(", ")}`,
|
|
failures,
|
|
};
|
|
}
|
|
|
|
const failGroup = failures[0].group;
|
|
|
|
if (failures.length > 1) {
|
|
return {
|
|
title: `Multiple errors in flaky test ${failGroup}`,
|
|
failures,
|
|
};
|
|
}
|
|
|
|
const failure = failures[0];
|
|
|
|
// Common patterns where we can always get a detailed title
|
|
const runtimeMatch = failure.headline.match(/Runtime in .+?: (?<error>.+)/);
|
|
if (runtimeMatch) {
|
|
const runtime = runtimeMatch.groups.error.trim();
|
|
|
|
const invalidTimerMatch = runtime.match(/^Invalid timer:.+object:(?<object>[^[]+).*delegate:(?<proc>.+?), source:/);
|
|
if (invalidTimerMatch) {
|
|
return {
|
|
title: `Flaky test ${failGroup}: Invalid timer: ${invalidTimerMatch.groups.proc.trim()} on ${invalidTimerMatch.groups.object.trim()}`,
|
|
failures,
|
|
};
|
|
}
|
|
|
|
return {
|
|
title: `Flaky test ${failGroup}: ${runtime}`,
|
|
failures,
|
|
};
|
|
}
|
|
|
|
const hardDelMatch = failure.headline.match(/^(?<object>\/[\w/]+) hard deleted .* times out of a total del count of/);
|
|
if (hardDelMatch) {
|
|
return {
|
|
title: `Flaky hard delete: ${hardDelMatch.groups.object}`,
|
|
failures,
|
|
};
|
|
}
|
|
|
|
// Try to normalize the title and remove anything that might be variable
|
|
const normalizedError = failure.headline.replace(/\s*at .+?:[0-9]+.*/g, ""); // "<message> at code.dm:123"
|
|
|
|
return {
|
|
title: `Flaky test ${failGroup}: ${normalizedError}`,
|
|
failures,
|
|
};
|
|
}
|
|
|
|
async function getExistingIssueId(graphql, context, title) {
|
|
// Hope you never have more than 100 of these open!
|
|
const {
|
|
repository: {
|
|
issues: { nodes: openFlakyTestIssues },
|
|
},
|
|
} = await graphql(
|
|
`
|
|
query ($owner: String!, $repo: String!, $label: String!) {
|
|
repository(owner: $owner, name: $repo) {
|
|
issues(
|
|
labels: [$label]
|
|
first: 100
|
|
orderBy: { field: CREATED_AT, direction: DESC }
|
|
states: [OPEN]
|
|
) {
|
|
nodes {
|
|
number
|
|
title
|
|
body
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
label: LABEL,
|
|
}
|
|
);
|
|
|
|
const exactTitle = openFlakyTestIssues.find((issue) => issue.title === title);
|
|
if (exactTitle !== undefined) {
|
|
return exactTitle.number;
|
|
}
|
|
|
|
const foundInBody = openFlakyTestIssues.find((issue) =>
|
|
issue.body.includes(`<!-- ${TITLE_BOT_HEADER}${exactTitle} -->`)
|
|
);
|
|
if (foundInBody !== undefined) {
|
|
return foundInBody.number;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function createBody({ title, failures }, runUrl) {
|
|
return `
|
|
<!-- This issue can be renamed, but do not change the next comment! -->
|
|
<!-- title: ${title} -->
|
|
|
|
Flaky tests were detected in [this test run](${runUrl}). This means that there was a failure that was cleared when the tests were simply restarted.
|
|
|
|
Failures:
|
|
\`\`\`
|
|
${failures
|
|
.map(
|
|
(failure) =>
|
|
`${failure.group}: ${failure.headline}\n\t${failure.details.join("\n")}`
|
|
)
|
|
.join("\n")}
|
|
\`\`\`
|
|
`.replace(/^\s*/gm, "");
|
|
}
|
|
|
|
export async function reportFlakyTests({ github, context }) {
|
|
const failedJobsFromLastRun = await getFailedJobsForRun(
|
|
github,
|
|
context,
|
|
context.payload.workflow_run.id,
|
|
context.payload.workflow_run.run_attempt - 1
|
|
);
|
|
|
|
// This could one day be relaxed if we face serious enough flaky test problems, so we're going to loop anyway
|
|
if (failedJobsFromLastRun.length !== 1) {
|
|
console.log(
|
|
"Multiple jobs failing after retry, assuming maintainer rerun."
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
for (const job of failedJobsFromLastRun) {
|
|
const { data: log } =
|
|
await github.rest.actions.downloadJobLogsForWorkflowRun({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
job_id: job.id,
|
|
});
|
|
|
|
const details = extractDetails(log);
|
|
|
|
const existingIssueId = await getExistingIssueId(
|
|
github.graphql,
|
|
context,
|
|
details.title
|
|
);
|
|
|
|
if (existingIssueId !== undefined) {
|
|
// Maybe in the future, if it's helpful, update the existing issue with new links
|
|
console.log(`Existing issue found: #${existingIssueId}`);
|
|
return;
|
|
}
|
|
|
|
await github.rest.issues.create({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
title: details.title,
|
|
labels: [LABEL],
|
|
body: createBody(
|
|
details,
|
|
`https://github.com/${context.repo.owner}/${
|
|
context.repo.repo
|
|
}/actions/runs/${context.payload.workflow_run.id}/attempts/${
|
|
context.payload.workflow_run.run_attempt - 1
|
|
}`
|
|
),
|
|
});
|
|
}
|
|
}
|