Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Restructure APG Support Tables #1053

Merged
merged 24 commits into from
May 21, 2024
Merged
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
243 changes: 55 additions & 188 deletions server/apps/embed.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@ const { create } = require('express-handlebars');
const { gql } = require('apollo-server-core');
const apolloServer = require('../graphql-server');
const staleWhileRevalidate = require('../util/staleWhileRevalidate');
const hash = require('object-hash');

const app = express();

const handlebarsPath = path.resolve(__dirname, '../handlebars/embed');

// handlebars
const hbs = create({
layoutsDir: path.resolve(handlebarsPath, 'views/layouts'),
extname: 'hbs',
Expand All @@ -30,10 +28,15 @@ app.set('views', path.resolve(handlebarsPath, 'views'));
// stale data for however long it takes for the query to complete.
const millisecondsUntilStale = 5000;

const queryReports = async () => {
const renderEmbed = async ({
queryTitle,
testPlanDirectory,
protocol,
host
}) => {
const { data, errors } = await apolloServer.executeOperation({
query: gql`
query {
query TestPlanQuery($testPlanDirectory: ID!) {
ats {
id
name
Expand All @@ -42,234 +45,98 @@ const queryReports = async () => {
name
}
}
testPlanReports(
testPlanVersionPhases: [CANDIDATE, RECOMMENDED]
isFinal: true
) {
id
metrics
at {
id
name
}
browser {
id
name
}
latestAtVersionReleasedAt {
id
name
releasedAt
}
testPlanVersion {
testPlan(id: $testPlanDirectory) {
testPlanVersions {
id
title
phase
updatedAt
testPlan {
testPlanReports(isFinal: true) {
id
}
tests {
ats {
metrics
at {
id
name
}
browser {
id
name
}
latestAtVersionReleasedAt {
id
name
releasedAt
}
}
}
}
}
`
`,
variables: { testPlanDirectory }
});

if (errors) {
throw new Error(errors);
}

const reportsHashed = hash(data.testPlanReports);

return {
allTestPlanReports: data.testPlanReports,
reportsHashed,
ats: data.ats
};
};

// As of now, a full query for the complete list of reports is needed to build
// the embed for a single pattern. This caching allows that query to be reused
// between pattern embeds.
const queryReportsCached = staleWhileRevalidate(queryReports, {
millisecondsUntilStale
});

const getLatestReportsForPattern = ({ allTestPlanReports, pattern }) => {
let title;

const testPlanReports = allTestPlanReports.filter(report => {
if (report.testPlanVersion.testPlan.id === pattern) {
title = report.testPlanVersion.title;
return true;
}
});

let allAts = new Set();
let allBrowsers = new Set();
let allAtVersionsByAt = {};
let reportsByAt = {};
let testPlanVersionIds = new Set();
const uniqueReports = [];
let latestReports = [];

testPlanReports.forEach(report => {
allAts.add(report.at.name);
allBrowsers.add(report.browser.name);
let testPlanVersion;

if (!allAtVersionsByAt[report.at.name])
allAtVersionsByAt[report.at.name] =
report.latestAtVersionReleasedAt;
else if (
new Date(report.latestAtVersionReleasedAt.releasedAt) >
new Date(allAtVersionsByAt[report.at.name].releasedAt)
) {
allAtVersionsByAt[report.at.name] =
report.latestAtVersionReleasedAt;
}
const recommendedTestPlanVersion = data.testPlan?.testPlanVersions.find(
testPlanVersion => testPlanVersion.phase === 'RECOMMENDED'
);

const sameAtAndBrowserReports = testPlanReports.filter(
r =>
r.at.name === report.at.name &&
r.browser.name === report.browser.name
if (data.testPlan && recommendedTestPlanVersion) {
testPlanVersion = recommendedTestPlanVersion;
} else if (data.testPlan) {
testPlanVersion = data.testPlan.testPlanVersions.find(
testPlanVersion => testPlanVersion.phase === 'CANDIDATE'
);
}

// Only add a group of reports with same
// AT and browser once
if (
!uniqueReports.find(group =>
group.some(
g =>
g.at.name === report.at.name &&
g.browser.name === report.browser.name
)
)
) {
uniqueReports.push(sameAtAndBrowserReports);
}

testPlanVersionIds.add(report.testPlanVersion.id);
});

uniqueReports.forEach(group => {
if (group.length <= 1) {
latestReports.push(group.pop());
} else {
const latestReport = group
.sort(
(a, b) =>
new Date(a.latestAtVersionReleasedAt.releasedAt) -
new Date(b.latestAtVersionReleasedAt.releasedAt)
)
.pop();

latestReports.push(latestReport);
const testPlanReports = (testPlanVersion?.testPlanReports ?? []).sort(
(a, b) => {
if (a.at.name !== b.at.name) {
return a.at.name.localeCompare(b.at.name);
}
return a.browser.name.localeCompare(b.browser.name);
}
});

allBrowsers = Array.from(allBrowsers).sort();
testPlanVersionIds = Array.from(testPlanVersionIds);

const allAtsAlphabetical = Array.from(allAts).sort((a, b) =>
a.localeCompare(b)
);
allAtsAlphabetical.forEach(at => {
reportsByAt[at] = latestReports
.filter(report => report.at.name === at)
.sort((a, b) => a.browser.name.localeCompare(b.browser.name));
});

const hasAnyCandidateReports = Object.values(reportsByAt).find(atReports =>
atReports.some(report => report.testPlanVersion.phase === 'CANDIDATE')
);
let phase = hasAnyCandidateReports ? 'CANDIDATE' : 'RECOMMENDED';

return {
title,
allBrowsers,
allAtVersionsByAt,
testPlanVersionIds,
phase,
reportsByAt
};
};

const renderEmbed = ({
ats,
allTestPlanReports,
queryTitle,
pattern,
protocol,
host
}) => {
const {
title,
allBrowsers,
allAtVersionsByAt,
testPlanVersionIds,
phase,
reportsByAt
} = getLatestReportsForPattern({ pattern, allTestPlanReports });
const allAtBrowserCombinations = Object.fromEntries(
ats.map(at => {
return [
at.name,
at.browsers.map(browser => {
return browser.name;
})
];
})
);

return hbs.renderView(path.resolve(handlebarsPath, 'views/main.hbs'), {
layout: 'index',
dataEmpty: Object.keys(reportsByAt).length === 0,
allAtBrowserCombinations,
title: queryTitle || title || 'Pattern Not Found',
pattern,
phase,
allBrowsers,
allAtVersionsByAt,
reportsByAt,
completeReportLink: `${protocol}${host}/report/${testPlanVersionIds.join(
','
)}`,
embedLink: `${protocol}${host}/embed/reports/${pattern}`
dataEmpty: !testPlanVersion?.testPlanReports.length,
title: queryTitle || testPlanVersion?.title || 'Pattern Not Found',
phase: testPlanVersion?.phase,
testPlanVersionId: testPlanVersion?.id,
testPlanReports,
protocol,
host,
completeReportLink: `${protocol}${host}/report/${testPlanVersion?.id}`,
embedLink: `${protocol}${host}/embed/reports/${testPlanDirectory}`
});
};

// Limit the number of times the template is rendered
// staleWhileRevalidate() caching allows this page to handle very high traffic like
// it will see on the APG website. It works by immediately serving a recent
// version of the page and checks for updates in the background.
const renderEmbedCached = staleWhileRevalidate(renderEmbed, {
getCacheKeyFromArguments: ({ reportsHashed, pattern }) =>
reportsHashed + pattern,
getCacheKeyFromArguments: ({ testPlanDirectory }) => testPlanDirectory,
millisecondsUntilStale
});

app.get('/reports/:pattern', async (req, res) => {
app.get('/reports/:testPlanDirectory', async (req, res) => {
// In the instance where an editor doesn't want to display a certain title
// as it has defined when importing into the ARIA-AT database for being too
// verbose, etc. eg. `Link Example 1 (span element with text content)`
// Usage: https://aria-at.w3.org/embed/reports/command-button?title=Link+Example+(span+element+with+text+content)
const queryTitle = req.query.title;
const pattern = req.params.pattern;
const testPlanDirectory = req.params.testPlanDirectory;
const host = req.headers.host;
const protocol = /dev|vagrant/.test(process.env.ENVIRONMENT)
? 'http://'
: 'https://';
const { allTestPlanReports, reportsHashed, ats } =
await queryReportsCached();
const embedRendered = await renderEmbedCached({
ats,
allTestPlanReports,
reportsHashed,
queryTitle,
pattern,
testPlanDirectory,
protocol,
host
});
Expand Down
69 changes: 19 additions & 50 deletions server/handlebars/embed/helpers/index.js
Original file line number Diff line number Diff line change
@@ -1,60 +1,29 @@
let map = {};

module.exports = {
isBrowser: function (a, b) {
return a === b;
},
isInAllBrowsers: function (value, object) {
return object.allBrowsers.includes(value);
dataEmpty: function (object) {
return object.length === 0;
},

isCandidate: function (value) {
return value === 'CANDIDATE';
},
getAtVersion: function (object, key) {
return object.allAtVersionsByAt[key].name;
getMustSupportData: function (object) {
return Math.trunc(
(object.metrics.mustAssertionsPassedCount /
object.metrics.mustAssertionsCount) *
100
);
},
combinationExists: function (object, atName, browserName) {
if (object.allAtBrowserCombinations[atName].includes(browserName)) {
return true;
}
return false;
isMustAssertionPriority: function (object) {
return object.metrics.mustAssertionsCount > 0;
},
elementExists: function (parentObject, childObject, at, key, last) {
const atBrowsers = childObject.map(o => o.browser.name);

if (!map[parentObject.pattern]) {
map[parentObject.pattern] = {};
}

if (!(at in map[parentObject.pattern])) {
map[parentObject.pattern][at] = {};
}

const moreThanOneColumn = Object.values(childObject).length > 1;

const conditional =
moreThanOneColumn &&
(key in map[parentObject.pattern][at] || atBrowsers.includes(key));

// Cache columns that don't have data
if (
!(key in map[parentObject.pattern][at]) &&
!atBrowsers.includes(key)
) {
map[parentObject.pattern][at][key] = true;
}

// Don't write to the Safari column unless it's the last element
if (!last && key === 'Safari' && !atBrowsers.includes(key)) {
return true;
} else if (last && key === 'Safari' && !atBrowsers.includes(key)) {
return false;
}

return conditional;
isShouldAssertionPriority: function (object) {
return object.metrics.shouldAssertionsCount > 0;
},
resetMap: function () {
map = {};
return;
getShouldSupportData: function (object) {
return Math.trunc(
(object.metrics.shouldAssertionsPassedCount /
object.metrics.shouldAssertionsCount) *
100
);
}
};
Loading
Loading