@virtualstate/app-history
Version:
Native JavaScript [app-history](https://github.com/WICG/app-history) implementation
609 lines (579 loc) • 20.4 kB
JavaScript
/* c8 ignore start */
import * as Playwright from "playwright";
import { deferred } from "../util/deferred.js";
import fs from "fs";
import path from "path";
import { getConfig } from "./config.js";
import * as Cheerio from "cheerio";
import { DependenciesSyncHTML } from "./dependencies.js";
import { v4 } from "uuid";
import { createJavaScriptBundle } from "./app-history.server.wpt.js";
const namespacePath = "/node_modules/wpt/app-history";
const namespaceBundlePath = "/app-history";
const buildPath = "/esnext";
const resourcesInput = "/resources";
const resourcesTarget = "/node_modules/wpt/resources";
const testWrapperFnName = `tests${v4().replace(/[^a-z0-9]/g, "")}`;
console.log({ testWrapperFnName });
const DEBUG = getConfig().FLAGS?.includes("DEBUG") || false;
const DEVTOOLS = getConfig().FLAGS?.includes("DEVTOOLS") || false;
const ONLY_FAILED = getConfig().FLAGS?.includes("ONLY_FAILED") || false;
const INCLUDE_SERVICE_WORKER = getConfig().FLAGS?.includes("INCLUDE_SERVICE_WORKER") || false;
const AT_A_TIME = DEBUG ? 1 : 60;
const TEST_RESULTS_PATH = "./node_modules/.wpt.test-results.json";
const ONLY = getConfig().ONLY;
const DEVTOOLS_SLOW_MO = undefined;
const browsers = [
["chromium", Playwright.chromium, { esm: true, args: [], FLAG: "" }],
];
// webkit and firefox do not support importmap
for (const [browserName, browserLauncher, { esm, args, FLAG }] of browsers.filter(([, browser]) => browser)) {
if (FLAG && !getConfig().FLAGS?.includes(FLAG)) {
continue;
}
const browser = await browserLauncher.launch({
headless: !DEVTOOLS,
devtools: DEVTOOLS,
args: [
...args
],
slowMo: DEVTOOLS ? DEVTOOLS_SLOW_MO : undefined
});
console.log(`Running WPT playwright tests for ${browserName} ${browser.version()}`);
const types = (await fs.promises.readdir(`.${namespacePath}`)).filter(value => !value.includes("."));
let urls = [
...(await Promise.all(types.map(async (type) => {
return (await fs.promises.readdir(`.${namespacePath}/${type}`))
.filter(value => value.endsWith(".html"))
.map(value => `/${type}/${value}`);
})))
.flatMap(value => value)
];
if (!ONLY && DEBUG) {
// urls = urls.slice(0, 3);
urls = urls.filter(url => url.includes("transitionWhile"));
}
console.log("STARTING:");
urls.forEach(url => console.log(` - ${url}`));
let total = 0, pass = 0, fail = 0,
// TODO use coverage trace to ensure this scripts lines are fully executed
linesTotal = 0, linesPass = 0, linesPassCovered = 0, urlsPass = [], urlsFailed = [], urlsSkipped = [];
if (ONLY) {
console.log(ONLY);
// urlsSkipped.push(...urls.filter(url => url !== ONLY))
urls = urls.filter(url => url === ONLY);
}
else if (ONLY_FAILED) {
const state = await readState();
if (Array.isArray(state.urlsFailed)) {
urls = state.urlsFailed;
if (Array.isArray(state.urlsPass)) {
urlsPass.push(...state.urlsPass);
}
}
}
if (!INCLUDE_SERVICE_WORKER && !ONLY) {
urlsSkipped.push(...urls.filter(url => url.includes("service-worker")));
urls = urls.filter(url => !url.includes("service-worker"));
}
let result = {};
while (urls.length) {
await Promise.all(Array.from({ length: AT_A_TIME }, () => urls.shift())
.filter(Boolean)
.map(async (url) => {
await withUrl(url);
result = {
total,
pass,
fail,
percent: Math.round(pass / total * 100 * 100) / 100,
linesTotal,
linesPass,
linesPassCovered,
percentLines: Math.round(linesPass / linesTotal * 100 * 100) / 100,
percentLinesCovered: Math.round(linesPassCovered / linesTotal * 100 * 100) / 100,
percentLinesCoveredMatched: Math.round(linesPassCovered / linesPass * 100 * 100) / 100
};
console.log(result);
}));
}
await fs.promises.writeFile("./coverage/wpt.results.json", JSON.stringify(result));
await browser.close();
console.log("Playwright tests complete");
console.log("PASSED:");
urlsPass.forEach(url => console.log(` - ${url}`));
console.log("\nFAILED:");
urlsFailed.forEach(url => console.log(` - ${url}`));
if (urlsSkipped.length) {
console.log("\nSKIPPED:");
urlsSkipped.forEach(url => console.log(` - ${url}`));
}
await writeState({
urlsPass,
urlsFailed
});
async function writeState(state) {
await fs.promises.writeFile(TEST_RESULTS_PATH, JSON.stringify(state, undefined, " "));
}
async function readState() {
return fs.promises.readFile(TEST_RESULTS_PATH, "utf-8").then(JSON.parse).catch(() => ({}));
}
async function withUrl(url) {
total += 1;
const context = await browser.newContext({});
const page = await context.newPage();
try {
console.log(`START ${url}`);
const html = await fs.promises.readFile(`.${namespacePath}/${url}`, 'utf-8');
const $ = Cheerio.load(html);
const lines = $("script:not([src])").html()?.split('\n').length ?? 0;
linesTotal += lines;
await page.coverage.startJSCoverage();
await run(browserName, browser, page, url);
const coverage = await page.coverage.stopJSCoverage();
const matchingFnEntry = coverage.find(entry => entry.functions.find(fn => fn.functionName === testWrapperFnName));
const matchingFn = matchingFnEntry.functions.find(fn => fn.functionName === testWrapperFnName);
const code = matchingFn.ranges
.filter(range => range.count > 0)
.map(range => matchingFnEntry.source.slice(range.startOffset, range.endOffset))
.join('');
// Minus 2 because this code includes the function definition, start bracket, and end bracket
const coveredLines = code.split('\n').length - 2;
// console.log(code.split('\n').length);
console.log(`PASS ${url} : ${lines} Lines`);
urlsPass.push(url);
pass += 1;
linesPass += lines;
linesPassCovered += coveredLines;
}
catch (error) {
console.log(`FAIL ${url}`, error);
fail += 1;
urlsFailed.push(url);
}
await page.close();
await context.close();
}
}
console.log(`PASS assertAppHistory:playwright:new AppHistory`);
async function run(browserName, browser, page, url) {
const { resolve, reject, promise } = deferred();
void promise.catch(error => error);
await page.exposeFunction("testsComplete", (details) => {
console.log(`Playwright tests complete tests for ${browserName} ${browser.version()}`, details);
return resolve();
});
await page.exposeFunction("testsFailed", (reason) => {
console.log(`Playwright tests failed tests for ${browserName} ${browser.version()}`, reason);
return reject(reason);
});
if (DEBUG) {
page.on('console', console.log);
}
else {
// you can comment out this one :)
page.on('console', console.log);
}
await page.route('**/*', async (route, request) => {
const routeUrl = new URL(request.url());
const { pathname } = routeUrl;
if (pathname.endsWith("foo.html")) {
return route.abort();
}
// console.log({ pathname, isResources: pathname.startsWith(resourcesInput), isBundle: pathname.startsWith(namespaceBundlePath) && pathname.endsWith(".html.js") });
if (pathname.startsWith(namespaceBundlePath) && pathname.endsWith(".html.js")) {
const contents = await createJavaScriptBundle(routeUrl);
return route.fulfill({
body: contents,
headers: {
"Content-Type": "application/javascript",
"Access-Control-Allow-Origin": "*"
}
});
}
else if (pathname.startsWith(namespacePath) || pathname.startsWith(resourcesInput) || pathname.startsWith(buildPath)) {
const { pathname: file } = new URL(import.meta.url);
let input = pathname.startsWith(resourcesInput) ? pathname.replace(resourcesInput, resourcesTarget) : pathname;
let importTarget = path.resolve(path.join(path.dirname(file), '../..', input));
if (!/\.[a-z]+$/.test(importTarget)) {
// console.log({ importTarget });
if (!importTarget.endsWith("/")) {
importTarget += "/";
}
importTarget += "index.js";
}
// console.log({ importTarget });
let contents = await fs.promises.readFile(importTarget, "utf-8").catch(() => "");
if (!contents) {
return route.abort();
}
// console.log({ importTarget, contents: !!contents });
let contentType = "application/javascript";
if (importTarget.endsWith(".html")) {
contentType = "text/html";
const html = await fs.promises.readFile(importTarget, "utf-8").catch(() => "");
const $ = Cheerio.load(html);
const globalNames = [
"appHistory",
"window",
"i",
"iframe",
"location",
"history",
"promise_test",
"test",
"test_driver",
// "assert_true",
// "assert_false",
// "assert_equals",
// "assert_not_equals",
// "assert_unreached",
"async_test",
"promise_rejects_dom",
"a",
"form",
"submit",
];
const targetUrl = `${namespaceBundlePath}${url}.js?exportAs=${testWrapperFnName}&globals=${globalNames.join(",")}`;
// console.log({ targetUrl, namespacePath, url })
const scriptText = `
globalThis.rv = [];
const { ${testWrapperFnName} } = await import("${targetUrl}&preferUndefined=1&debugger=${DEVTOOLS ? "1" : ""}");
const {
AppHistory,
InvalidStateError,
AppHistoryTransitionFinally,
AppHistorySync,
EventTarget,
AppHistoryUserInitiated,
AppHistoryFormData
} = await import("/esnext/index.js");
// if (${DEVTOOLS}) {
// await new Promise(resolve => setTimeout(resolve, 2500));
// }
let appHistoryTarget = new AppHistory({
initialUrl: globalThis.window.location.href
});
function proxyAppHistory(appHistory, get) {
return new Proxy(appHistory, {
get(u, property) {
const currentTarget = get();
const value = currentTarget[property];
if (typeof value === "function") return value.bind(currentTarget);
return value;
}
});
}
const appHistory = proxyAppHistory(appHistoryTarget, () => appHistoryTarget);
const location = (
new AppHistorySync({
appHistory
})
),
history = location;
globalThis.appHistory = appHistory;
appHistory.addEventListener("navigateerror", console.error);
async function navigateFinally(appHistory, url) {
// This allows us to wait for the navigation to fully settle before starting
const initialNavigationFinally = new Promise((resolve) => appHistory.addEventListener(AppHistoryTransitionFinally, resolve, { once: true }));
// Initialise first navigation to emulate a page loaded
await appHistory.navigate(url).finished;
await initialNavigationFinally;
}
await navigateFinally(appHistoryTarget, "/");
const Event = CustomEvent;
const windowEvents = new EventTarget();
let firstLoad = false;
const window = {
set onload(value) {
if (!firstLoad) {
console.log("first window onload");
value();
firstLoad = false;
} else {
console.log("next window onload");
windowEvents.addEventListener("load", value, { once: true });
}
},
appHistory,
stop() {
if (appHistory.transition) {
return appHistory.transition.rollback();
}
}
};
let iframeAppHistoryTarget = new AppHistory({
initialUrl: globalThis.window.location.href
});
const iframeAppHistory = proxyAppHistory(iframeAppHistoryTarget, () => iframeAppHistoryTarget);
await navigateFinally(iframeAppHistoryTarget, "/");
const iframeLocation = (
new AppHistorySync({
appHistory
})
),
iframeHistory = iframeLocation;
const iframeEvents = new EventTarget();
const iframe = {
contentWindow: {
appHistory: iframeAppHistory,
DOMException: InvalidStateError,
history: iframeHistory,
location: iframeLocation,
set onload(value) {
iframeEvents.addEventListener("load", value, { once: true });
},
},
remove() {
iframeAppHistoryTarget = new AppHistory({
initialUrl: globalThis.window.location.href
});
},
set onload(value) {
iframeEvents.addEventListener("load", (e) => {
console.log("load", e);
return value(e)
}, { once: true });
},
set src(value) {
run().then(() => console.log("navigated")).catch(console.log)
async function run() {
const url = value.toString();
await iframe.contentWindow.appHistory.navigate(url)
.finished;
}
}
};
const i = iframe;
const testSteps = [];
// Wait for all navigations to settle
appHistory.addEventListener("currentchange", () => {
const finished = appHistory.transition?.finished;
if (finished) {
testSteps.push(async () => {
try {
await finished;
} catch {}
});
}
})
iframe.contentWindow.appHistory.addEventListener("navigate", () => {
console.log("navigate iframe");
const handler = (e) => {
if (e.type === "navigatesuccess") {
console.log("dispatch load");
iframeEvents.dispatchEvent({ type: "load" });
} else {
console.log("dispatch error");
iframeEvents.dispatchEvent({ type: "error" });
}
iframe.contentWindow.appHistory.removeEventListener("navigatesuccess", handler, { once: true });
iframe.contentWindow.appHistory.removeEventListener("navigateerror", handler, { once: true });
}
iframe.contentWindow.appHistory.addEventListener("navigatesuccess", handler, { once: true });
iframe.contentWindow.appHistory.addEventListener("navigateerror", handler, { once: true });
})
window.open = (url, target) => {
if (target === "i" || target === "iframe") {
return iframe.contentWindow.appHistory.navigate(url);
}
}
const a = new EventTarget();
a.href = ${JSON.stringify($("a[href]")?.attr("href") || "#1")};
a.click = (e) => {
let targetAppHistory = appHistory,
targetLocation = location;
return targetAppHistory.navigate(new URL(a.href, targetLocation.href).toString(), e);
}
const form = new EventTarget();
form.action = ${JSON.stringify($("form[action]")?.attr("action") || "")};
form.method = ${JSON.stringify($("form[method]")?.attr("method") || "post")};
form.target = ${JSON.stringify($("form[target]")?.attr("target") || "")};
form.submit = (e) => {
let targetAppHistory = appHistory,
targetLocation = location;
if (form.target === "i" || form.target === "iframe") {
targetAppHistory = iframe.contentWindow.appHistory;
targetLocation = iframe.contentWindow.location;
}
const action = form.action ? new URL(form.action, targetLocation.href).toString() : targetLocation.href;
return targetAppHistory.navigate(action, {
...e,
[AppHistoryFormData]: new FormData()
});
}
const submit = new EventTarget();
submit.type = "submit";
submit.click = (e) => {
return form.submit(e);
}
const details = {
tests: 0,
assert_true: 0,
assert_false: 0,
assert_equals: 0,
assert_not_equals: 0,
step_timeout: 0,
step_func: 0,
step_func_done: 0,
promise_rejects_dom: 0,
done: 0,
unreached_func: 0
}
function promise_test(fn) {
testSteps.push(fn);
details.tests += 1;
}
function async_test(fn) {
testSteps.push(fn);
details.tests += 1;
}
function test(fn) {
testSteps.push(fn);
details.tests += 1;
}
function assert_true(value, message = "Expected true") {
details.assert_true += 1;
// console.log(value);
if (value !== true) {
throw new Error(message);
}
}
function assert_false(value, message = "Expected false") {
details.assert_false += 1;
// console.log(value);
if (value !== false) {
throw new Error(message);
}
}
function assert_equals(left, right) {
details.assert_equals += 1;
// console.log(JSON.stringify({ left, right }));
if (left !== right) {
throw new Error("Expected values to equal");
}
}
function assert_not_equals(left, right) {
details.assert_not_equals += 1;
// console.log(JSON.stringify({ left, right }));
if (left === right) {
throw new Error("Expected values to not equal");
}
}
async function promise_rejects_dom(test, name, promise) {
details.promise_rejects_dom += 1;
let caught;
try {
await promise;
} catch (error) {
caught = error;
}
if (!caught) {
throw new Error("Expected promise rejection");
}
}
function assert_unreached() {
const error = new Error("Did not expect to reach here");
testSteps.push(() => Promise.reject(error));
throw error;
}
const t = {
step_timeout(resolve, timeout) {
details.step_timeout += 1;
setTimeout(resolve, timeout);
},
step_func(fn) {
details.step_func += 1;
let resolve, reject;
const promise = new Promise((resolveFn, rejectFn) => { resolve = resolveFn; reject = rejectFn; });
testSteps.push(() => promise);
return (...args) => {
try {
const result = fn(...args);
if (result && typeof result === "object" && "then" in result) {
return result.then(resolve, reject).then(() => result);
} else {
resolve();
}
return result;
} catch (error) {
reject(error);
throw error;
}
}
},
step_func_done(fn) {
details.step_func_done += 1;
return t.step_func(fn);
},
done(fn) {
details.done += 1;
return t.step_func(fn);
},
unreached_func(message) {
details.unreached_func += 1;
return () => testSteps.push(() => Promise.reject(new Error(message)));
}
}
const test_driver = {
click(element) {
return element.click({
[AppHistoryUserInitiated]: true
});
}
}
console.log("Starting tests");
await ${testWrapperFnName}({
${globalNames.join(",\n")}
});
if (!testSteps.length) {
console.error("No tests configured");
globalThis.window.testsFailed("No tests configured");
} else {
try {
await Promise.all(testSteps.map(async test => test(t)));
if (${DEVTOOLS}) console.log("PASS");
globalThis.window.testsComplete(JSON.stringify(details));
} catch (error) {
if (${DEVTOOLS}) console.log("FAIL", error);
globalThis.window.testsFailed(error.toString() + " " + error.stack);
throw error;
}
}
`.trim();
contents = `
${DependenciesSyncHTML}
<script type="module">${scriptText}</script>
`.trim();
//
// console.log(contents);
}
return route.fulfill({
body: contents,
headers: {
"Content-Type": contentType,
"Access-Control-Allow-Origin": "*"
}
});
}
return route.continue();
});
await page.goto(`https://example.com${namespacePath}${url}`, {});
// console.log("Navigation started");
await page.waitForLoadState("load");
// console.log("Loaded document");
await page.waitForLoadState("networkidle");
// console.log("Network idle");
setTimeout(reject, 30000, new Error("Timeout"));
try {
await promise;
}
finally {
if (DEVTOOLS) {
await new Promise(() => void 0);
// await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}
/* c8 ignore end */
//# sourceMappingURL=app-history.playwright.wpt.js.map