Skip to content

Commit f267cb6

Browse files
author
Shane Osbourne
committedDec 27, 2023
Merge remote-tracking branch 'origin/09-09-adding_playwright_tests'
2 parents d787281 + 97f08e7 commit f267cb6

38 files changed

+976
-4874
lines changed
 

‎.github/workflows/main.yml

+9-1
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,13 @@ jobs:
3737
run: npm ci
3838
- name: Test
3939
run: npm test
40-
- name: Test E2E
40+
- name: Install Playwright Browsers
41+
run: npx playwright install --with-deps
42+
- name: Run Playwright tests
4143
run: npm run test:e2e
44+
- uses: actions/upload-artifact@v3
45+
if: always()
46+
with:
47+
name: playwright-report
48+
path: playwright-report/
49+
retention-days: 30

‎.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,7 @@ client/dist/index.js
2525
client/dist/index.js.map
2626
client/dist/index.min.js
2727
client/dist/index.min.js.map
28+
node_modules/
29+
/test-results/
30+
/playwright-report/
31+
/playwright/.cache/

‎crossbow.yaml

-31
This file was deleted.

‎cypress.json

-3
This file was deleted.

‎cypress/configs/css-console-notify.js

-12
This file was deleted.

‎cypress/configs/css-overlay-notify.js

-9
This file was deleted.

‎cypress/configs/file-reloading.js

-10
This file was deleted.

‎cypress/configs/file-watching-ignore.js

-7
This file was deleted.

‎cypress/configs/logPrefix.js

-10
This file was deleted.

‎cypress/configs/no-notify.js

-9
This file was deleted.

‎cypress/fixtures/example.json

-5
This file was deleted.

‎cypress/fixtures/profile.json

-5
This file was deleted.

‎cypress/fixtures/users.json

-232
This file was deleted.

‎cypress/integration.backup/example_spec.js

-1,497
This file was deleted.

‎cypress/integration/connection-notify.js

-12
This file was deleted.

‎cypress/integration/css-console-notify.js

-26
This file was deleted.

‎cypress/integration/css-overlay-notify.js

-13
This file was deleted.

‎cypress/integration/file-reloading.js

-119
This file was deleted.

‎cypress/integration/file-watching-ignore.js

-14
This file was deleted.

‎cypress/integration/logPrefix.js

-22
This file was deleted.

‎cypress/integration/no-notify.js

-14
This file was deleted.

‎cypress/integration/ui-remote-debug.js

-28
This file was deleted.

‎cypress/plugins/index.js

-17
This file was deleted.

‎cypress/setup/bs-cli.js

-46
This file was deleted.

‎cypress/setup/bs.js

-35
This file was deleted.

‎cypress/support/commands.js

-25
This file was deleted.

‎cypress/support/index.js

-20
This file was deleted.

‎examples/basic/run.js

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const bs = require("../../packages/browser-sync/dist/index").create();
2+
const path = require("path");
3+
const serverDir = path.join(__dirname, "..", "..", "packages/browser-sync/test/fixtures");
4+
5+
bs.init(
6+
{
7+
server: serverDir,
8+
open: false,
9+
watch: true,
10+
online: false
11+
},
12+
(err, bs) => {
13+
const message = {
14+
kind: "ready",
15+
urls: bs.options.get("urls").toJS(),
16+
cwd: serverDir
17+
};
18+
if (process.send) {
19+
process.send(message);
20+
} else {
21+
console.log(message);
22+
}
23+
}
24+
);

‎examples/snippet/index.html

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta
6+
name="viewport"
7+
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
8+
/>
9+
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
10+
<title>Document</title>
11+
</head>
12+
<body>
13+
<h1>Hello world</h1>
14+
</body>
15+
</html>

‎examples/snippet/run.js

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const bs = require("../../packages/browser-sync/dist/index").create();
2+
bs.init(
3+
{
4+
server: ".",
5+
open: false,
6+
notify: false,
7+
watch: true,
8+
snippetOptions: {
9+
rule: {
10+
match: /<\/head>/i,
11+
fn: function(snippet, match) {
12+
return snippet + match;
13+
}
14+
}
15+
}
16+
},
17+
(err, bs) => {
18+
const message = {
19+
kind: "ready",
20+
urls: bs.options.get("urls").toJS(),
21+
cwd: __dirname
22+
};
23+
if (process.send) {
24+
process.send(message);
25+
} else {
26+
console.log(message);
27+
}
28+
}
29+
);

‎package-lock.json

+373-2,647
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+6-5
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@
55
"bootstrap": "lerna bootstrap",
66
"postinstall": "npm run bootstrap",
77
"test": "lerna run build && lerna run test --scope browser-sync",
8-
"test:e2e": "cb cy:file-reloading cy:ui-remote-debug cy:connection-notify"
8+
"test:e2e": "echo skipping cypress",
9+
"test:playwright": "playwright test"
910
},
1011
"devDependencies": {
1112
"lerna": "^6.1.0"
1213
},
1314
"dependencies": {
14-
"crossbow": "^4.6.0",
15-
"cypress": "^9.5.1",
16-
"rxjs": "^7.5.4"
15+
"@playwright/test": "^1.37.1",
16+
"rxjs": "^7.5.4",
17+
"zod": "^3.22.2"
1718
},
18-
"nx": {}
19+
"nx": {}
1920
}

‎playwright.config.ts

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
/**
4+
* See https://playwright.dev/docs/test-configuration.
5+
*/
6+
export default defineConfig({
7+
testDir: "./tests",
8+
/* Run tests in files in parallel */
9+
fullyParallel: true,
10+
/* Fail the build on CI if you accidentally left test.only in the source code. */
11+
forbidOnly: !!process.env.CI,
12+
/* Retry on CI only */
13+
retries: process.env.CI ? 2 : 0,
14+
/* Opt out of parallel tests on CI. */
15+
workers: process.env.CI ? 1 : 1,
16+
// workers: 1,
17+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
18+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
19+
use: {
20+
/* Base URL to use in actions like `await page.goto('/')`. */
21+
// baseURL: 'http://127.0.0.1:3000',
22+
23+
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
24+
trace: "on-first-retry"
25+
},
26+
projects: [
27+
{
28+
name: "snippet-options",
29+
use: {
30+
...devices["Desktop Chrome"]
31+
},
32+
testDir: "tests/examples/snippet"
33+
},
34+
{
35+
name: "basic",
36+
use: {
37+
...devices["Desktop Chrome"]
38+
},
39+
testDir: "tests/examples/basic"
40+
},
41+
]
42+
});

‎tests/examples/basic/basic.spec.ts

+187
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { expect } from "@playwright/test";
2+
import { makesRequestIn, responseFor, test } from "../../utils";
3+
4+
test.describe("basic", () => {
5+
test("file reloading when .css changes", async ({ page, bs }) => {
6+
await page.goto(bs.url);
7+
const request = makesRequestIn(page, {
8+
matches: {
9+
url: url => {
10+
return url.searchParams.has("browsersync");
11+
}
12+
}
13+
});
14+
await bs.touch("**/style.css");
15+
await request;
16+
});
17+
test("file reloading when HTTP received", async ({ page, bs }) => {
18+
await page.goto(bs.url);
19+
const request = makesRequestIn(page, {
20+
matches: {
21+
url: url => {
22+
return url.searchParams.has("browsersync");
23+
}
24+
}
25+
});
26+
const url = new URL("__browser_sync__", bs.url);
27+
await fetch(url.toString(), {
28+
method: "POST",
29+
body: JSON.stringify([
30+
"file:reload",
31+
{
32+
ext: "css",
33+
path: "*.css",
34+
basename: "*/*.css",
35+
event: "change",
36+
type: "inject",
37+
log: true
38+
}
39+
]),
40+
headers: {
41+
"content-type": "application/json"
42+
}
43+
});
44+
await request;
45+
});
46+
test("can import 1 stylesheet from <style>@import</style>", async ({ page, bs }) => {
47+
await page.goto(bs.url + "/import.html");
48+
49+
// make sure the search param is absent to prevent false
50+
const href = await page.evaluate(() => {
51+
const firstStyle = document.getElementsByTagName("style")[0];
52+
// @ts-expect-error
53+
return firstStyle.sheet.cssRules[0].href;
54+
});
55+
expect(href).not.toContain("browsersync");
56+
57+
// now change a file
58+
bs.touch("assets/import.css");
59+
60+
// eventually, the css file should be updated
61+
await page.waitForFunction(() => {
62+
const firstStyle = document.getElementsByTagName("style")[0];
63+
// @ts-expect-error
64+
return firstStyle.sheet.cssRules[0].href.includes("?browsersync");
65+
});
66+
});
67+
test.skip("can import nested from nested @import rules", async ({ page, bs }) => {
68+
await page.goto(bs.url + "/import-link.html");
69+
70+
// make sure the search param is absent to prevent false
71+
const href = await page.evaluate(() => {
72+
// @ts-expect-error
73+
return document.styleSheets[0].cssRules[0].href;
74+
});
75+
expect(href).not.toContain("browsersync");
76+
77+
// now change a file
78+
bs.touch("assets/import2.css");
79+
80+
// eventually, the css file should be updated
81+
await page.waitForFunction(() => {
82+
// @ts-expect-error
83+
const link = document.getElementsByTagName("link")?.[0].sheet?.cssRules?.[0].href;
84+
return (link || "").includes("?browsersync");
85+
});
86+
});
87+
test("should reload single <img src>", async ({ page, bs }) => {
88+
await page.goto(bs.url + "/images.html");
89+
90+
const request = makesRequestIn(page, {
91+
matches: {
92+
url: url => {
93+
return url.searchParams.has("browsersync");
94+
}
95+
}
96+
});
97+
98+
// now change a file
99+
bs.touch("**/cam-secure.png");
100+
await request;
101+
102+
const img = await page.$("img");
103+
expect(await img.getAttribute("src")).toContain("?browsersync");
104+
105+
// eventually, the img file should be updated
106+
const elem2 = await page.$('[id="img-style"]');
107+
const style = await elem2.getAttribute("style");
108+
expect(style).not.toContain("?browsersync");
109+
});
110+
test("should reload single style backgroundImage style property", async ({ page, bs }) => {
111+
await page.goto(bs.url + "/images.html");
112+
113+
const request = makesRequestIn(page, {
114+
matches: {
115+
url: url => {
116+
return url.searchParams.has("browsersync");
117+
}
118+
}
119+
});
120+
121+
// now change a file
122+
bs.touch("**/cam-secure-02.png");
123+
await request;
124+
125+
const img = await page.$("img");
126+
expect(await img.getAttribute("src")).not.toContain("?browsersync");
127+
128+
const elem2 = await page.$('[id="img-style"]');
129+
const style = await elem2.getAttribute("style");
130+
expect(style).toContain("?browsersync");
131+
});
132+
test("should reload both images", async ({ page, bs }) => {
133+
await page.goto(bs.url + "/images.html");
134+
135+
const request = makesRequestIn(page, {
136+
matches: {
137+
url: url => {
138+
return url.searchParams.has("browsersync");
139+
}
140+
}
141+
});
142+
143+
// now change a file
144+
bs.touch("**/*.png");
145+
await request;
146+
147+
const img = await page.$("img");
148+
expect(await img.getAttribute("src")).toContain("?browsersync");
149+
150+
const elem2 = await page.$('[id="img-style"]');
151+
const style = await elem2.getAttribute("style");
152+
expect(style).toContain("?browsersync");
153+
});
154+
});
155+
156+
test.describe("UI", () => {
157+
test("remote debugger", async ({ context, page, bs }) => {
158+
await page.goto(bs.url);
159+
const request = makesRequestIn(page, {
160+
matches: {
161+
pathname: "/browser-sync/pesticide.css"
162+
}
163+
});
164+
165+
// open the UI
166+
const ui = await context.newPage();
167+
await ui.goto(bs.uiUrl);
168+
await ui.getByRole("button", { name: "Remote Debug" }).click();
169+
await ui
170+
.locator("label")
171+
.first()
172+
.click();
173+
174+
// back to the main page
175+
await page.bringToFront();
176+
177+
// ensure the CSS file was requested
178+
await request;
179+
});
180+
});
181+
182+
test.describe("Overlays", () => {
183+
test("should flash Connected message", async ({ context, page, bs }) => {
184+
await page.goto(bs.url);
185+
await page.locator("#__bs_notify__").waitFor({ timeout: 5000 });
186+
});
187+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { expect } from "@playwright/test";
2+
import { readFileSync } from "node:fs";
3+
import { join } from "node:path";
4+
import { makesRequestIn, responseFor, test } from "../../utils";
5+
6+
test.describe("html-inject", () => {
7+
test("injecting HTML", async ({ page, bs }) => {
8+
await page.goto(bs.url);
9+
10+
// fill the form field
11+
await page.getByLabel('Name:').fill('shane');
12+
13+
// read the original contents
14+
const original = readFileSync(join(bs.cwd, 'index.html'), 'utf8');
15+
16+
// fake the response to indicate a HTML change on disk
17+
await page.route('**', (route, req) => {
18+
const url = new URL(req.url());
19+
if (url.pathname === '/') {
20+
return route.fulfill({
21+
body: original.replace('Name:', 'FirstName:'),
22+
status: 200,
23+
contentType: "text/html"
24+
})
25+
}
26+
return route.continue()
27+
});
28+
29+
// wait for the request to happen
30+
const request = makesRequestIn(page, {
31+
matches: {
32+
pathname: "/"
33+
}
34+
});
35+
36+
// simulate the file-change
37+
await bs.touch("index.html");
38+
39+
// wait for BS to re-request the file
40+
await request;
41+
42+
// now ensure the Label was updated + the form values are still present
43+
await expect(page.getByLabel('FirstName:')).toHaveValue('shane', {timeout: 1000});
44+
});
45+
})
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { expect } from "@playwright/test";
2+
import { makesRequestIn, test } from "../../utils";
3+
4+
test("Snippet works", async ({ page, bs }) => {
5+
await makesRequestIn(page, {
6+
matches: { pathname: "/browser-sync/socket.io/" },
7+
when: { pageLoads: { url: bs.url } }
8+
});
9+
});
10+
11+
test("Gives correct terminal output for file watching", async ({ page, bs }) => {
12+
await page.goto(bs.url);
13+
const prevCount = bs.stdout.length;
14+
await bs.touch("index.html");
15+
const stdout = await bs.next({ stdout: { lines: { count: 1, after: prevCount } } });
16+
expect(stdout).toContain("[Browsersync] Reloading Browsers...\n");
17+
});
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { expect } from "@playwright/test";
2+
import { test, responseFor } from "../../utils";
3+
4+
test("startup writes CSS file", async ({ page, bs }) => {
5+
const [body] = await responseFor({
6+
pathname: "/dist/output.css",
7+
when: { page, loads: { url: bs.url } }
8+
});
9+
expect(body).toContain(`! tailwindcss`);
10+
});

‎tests/utils.ts

+215
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { Page, test as base, Response as PlaywrightResponse } from "@playwright/test";
2+
import { join, relative } from "node:path";
3+
import { fork, execSync } from "node:child_process";
4+
import * as z from "zod";
5+
import strip from "strip-ansi";
6+
7+
const messageSchema = z.discriminatedUnion("kind", [
8+
z.object({
9+
kind: z.literal("ready"),
10+
urls: z.object({
11+
local: z.string(),
12+
ui: z.string()
13+
}),
14+
cwd: z.string()
15+
})
16+
]);
17+
type Msg = z.infer<typeof messageSchema>;
18+
19+
interface NextArgs {
20+
stdout: { lines: { count: number; after: number } };
21+
}
22+
23+
export const test = base.extend<{
24+
bs: {
25+
url: string;
26+
cwd: string;
27+
uiUrl: string;
28+
child: any;
29+
stdout: string[];
30+
touch: (path: string) => void;
31+
next: (args: NextArgs) => Promise<string[]>;
32+
};
33+
}>({
34+
bs: async ({}, use, testInfo) => {
35+
const last2 = testInfo.file.split("/").slice(-3, -1);
36+
const cwd = process.cwd();
37+
const base = join(cwd, ...last2);
38+
const file = join(base, "run.js");
39+
const stdout: string[] = [];
40+
41+
// console.log("running", {
42+
// cwd,
43+
// base: relative(cwd, base),
44+
// file: relative(cwd, file),
45+
// testInfoFile: relative(cwd, testInfo.file)
46+
// });
47+
48+
const child = fork(file, {
49+
cwd: base,
50+
stdio: "pipe"
51+
});
52+
53+
child.stdout.on("data", d => {
54+
stdout.push(d.toString());
55+
// console.log(d.toString());
56+
});
57+
child.stderr.on("data", d => console.error(d.toString()));
58+
59+
const msg = await new Promise<Msg>((res, rej) => {
60+
child.on("spawn", (...args) => {
61+
// console.log("✅ spawned");
62+
});
63+
child.on("error", error => {
64+
rej(new Error("child process error" + error));
65+
});
66+
child.on("message", message => {
67+
// console.log("📩 message", message);
68+
const parsed = messageSchema.safeParse(message);
69+
if (parsed.success) {
70+
res(parsed.data);
71+
} else {
72+
// @ts-expect-error - not sure why this is not working
73+
rej(new Error("zod parsing error" + parsed.error));
74+
}
75+
});
76+
});
77+
78+
// console.log("---", msg.cwd);
79+
80+
const closed = new Promise<any>((res, rej) => {
81+
child.on("exit", code => {
82+
// console.log("exit, code: ", code);
83+
});
84+
child.on("close", code => {
85+
// console.log("[child]: close", code);
86+
res(code);
87+
});
88+
child.on("disconnect", (...args) => {
89+
// console.log("disconnect", ...args);
90+
});
91+
});
92+
93+
await use({
94+
url: msg.urls.local,
95+
uiUrl: msg.urls.ui,
96+
cwd: msg.cwd,
97+
child,
98+
stdout,
99+
touch: (path: string) => {
100+
touchFile(join(msg.cwd, path));
101+
},
102+
next: async (args: NextArgs) => {
103+
const {
104+
stdout: { lines }
105+
} = args;
106+
return new Promise<string[]>((res, rej) => {
107+
const timeout = setTimeout(() => {
108+
clearTimeout(timeout);
109+
clearInterval(int);
110+
rej(new Error("timeout"));
111+
}, 2000);
112+
const int = setInterval(() => {
113+
if (stdout.length > lines.after) {
114+
const dif = stdout.length - lines.after;
115+
if (dif >= lines.count) {
116+
const next = stdout.slice(lines.after).map(line => {
117+
return strip(line);
118+
});
119+
res(next);
120+
clearInterval(int);
121+
}
122+
}
123+
}, 100);
124+
});
125+
}
126+
});
127+
128+
child.kill("SIGTERM");
129+
130+
await closed;
131+
}
132+
});
133+
134+
interface RequestForArgs {
135+
matches:
136+
| {
137+
url: (url: URL) => boolean;
138+
}
139+
| {
140+
pathname: string;
141+
};
142+
when?: {
143+
pageLoads: {
144+
url: string;
145+
};
146+
};
147+
}
148+
149+
export function makesRequestIn(page: Page, args: RequestForArgs) {
150+
const { matches, when } = args;
151+
152+
if ("pathname" in matches) {
153+
const requestPromise = new Promise(res => {
154+
page.on("request", r => {
155+
const url = new URL(r.url());
156+
if (url.pathname === matches.pathname) {
157+
res(url);
158+
}
159+
});
160+
});
161+
const jobs = [requestPromise];
162+
if (when) {
163+
jobs.push(page.goto(when.pageLoads.url));
164+
}
165+
const good = Promise.all(jobs);
166+
return Promise.race([
167+
good,
168+
timeout({ msg: "waiting for request timed out: " + matches.pathname })
169+
]);
170+
}
171+
172+
if ("url" in matches) {
173+
const requestPromise = new Promise(res => {
174+
page.on("request", r => {
175+
const url = new URL(r.url());
176+
if (matches.url(url)) {
177+
res(url);
178+
}
179+
});
180+
});
181+
182+
return Promise.race([requestPromise, timeout({ msg: "waiting for request timed out" })]);
183+
}
184+
}
185+
186+
interface ResponseForArgs {
187+
pathname: string;
188+
when: { page: Page; loads: { url: string } };
189+
}
190+
191+
export async function responseFor(args: ResponseForArgs) {
192+
const { when, pathname } = args;
193+
const responseBody = new Promise<Buffer>(res => {
194+
when.page.on("response", r => {
195+
const url = new URL(r.url());
196+
if (url.pathname === pathname) {
197+
res(r.body());
198+
}
199+
});
200+
}).then(x => x.toString());
201+
202+
const load = when.page.goto(when.loads.url);
203+
const good = Promise.all([responseBody, load]);
204+
return Promise.race([good, timeout({ msg: "waiting for response timed out: " + pathname })]);
205+
}
206+
207+
function timeout({ ms, msg }: { ms?: number; msg?: string } = {}): Promise<never> {
208+
return new Promise((res, rej) =>
209+
setTimeout(() => rej(new Error(msg || "timedout")), ms ?? 2000)
210+
);
211+
}
212+
213+
function touchFile(filePath) {
214+
execSync(`touch ${filePath}`);
215+
}

0 commit comments

Comments
 (0)
Please sign in to comment.