test: playwright based integration tests

fix-build-duplicate-code
Nikhil Saraf 2 years ago
parent d637b5c1d3
commit 33a5dd27df

3
.gitignore vendored

@ -23,6 +23,9 @@ node_modules
*.launch
.settings/
.tmp
test/playwright-report
# Temp
gitignore

@ -1,85 +0,0 @@
import { test, expect } from "@playwright/test";
let javaScriptEnabled = !process.env.DISABLE_JAVASCRIPT;
test("login test " + (javaScriptEnabled ? "with js" : "without js"), async ({ browser }) => {
let appURL = new URL(process.env.TEST_HOST ?? "http://localhost:3000/").href;
const context = await browser.newContext({
javaScriptEnabled
});
const page = await context.newPage();
// go to home
await page.goto(appURL);
console.log(`redirect to login page`);
await page.waitForURL(new URL("/login", appURL).href);
console.log("testing wrong password");
await page.fill('input[name="username"]', "kody");
await page.fill('input[name="password"]', "twixroxx");
await page.click("button[type=submit]");
// console.log(page.url())
// console.log(await page.content())
// console.log(page.url())
if (!javaScriptEnabled) {
await page.waitForURL(/Username%2FPassword%20combination%20is%20incorrect%22/);
}
await expect(page.locator("#error-message")).toHaveText(
"Username/Password combination is incorrect"
);
// await page.click("#reset-errors");
console.log("testing wrong username");
await page.fill('input[name="username"]', "kod");
await page.fill('input[name="password"]', "twixrox");
await page.click("button[type=submit]");
if (!javaScriptEnabled) {
await page.waitForURL(/Username%2FPassword%20combination%20is%20incorrect%22/);
}
await expect(page.locator("#error-message")).toHaveText(
"Username/Password combination is incorrect",
{
timeout: 10000
}
);
// await page.click("#reset-errors");
console.log("testing invalid password");
await page.fill('input[name="username"]', "kody");
await page.fill('input[name="password"]', "twix");
await page.click("button[type=submit]");
if (!javaScriptEnabled) {
await page.waitForURL(/Fields%20invalid/);
}
await expect(page.locator("#error-message")).toHaveText("Fields invalid");
// await page.click("#reset-errors");
console.log("login");
await page.fill('input[name="username"]', "kody");
await page.fill('input[name="password"]', "twixrox");
await page.click("button[type=submit]");
console.log(`redirect to home after login`);
await page.waitForURL(appURL);
console.log(`going to login page should redirect to home page since we are logged in`);
await page.goto(new URL("/login", appURL).href);
await page.waitForURL(appURL);
console.log(`logout`);
await page.click("button[name=logout]");
await page.waitForURL(new URL("/login", appURL).href);
console.log(`going to home should redirect to login`);
await page.goto(appURL);
await page.waitForURL(new URL("/login", appURL).href);
});

@ -4,22 +4,16 @@
"scripts": {
"dev": "solid-start dev",
"build": "solid-start build",
"start": "solid-start start",
"test": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 test:e2e",
"test:e2e": "cross-env TEST_HOST=http://localhost:$PORT/ npm-run-all -p test:e2e:*",
"test:e2e:js": "playwright test",
"test:e2e:no-js": "cross-env DISABLE_JAVASCRIPT=true playwright test"
"start": "solid-start start"
},
"type": "module",
"devDependencies": {
"@cloudflare/kv-asset-handler": "^0.1.1",
"@playwright/test": "1.20.0",
"@vitest/ui": "^0.2.7",
"cookie": "^0.4.1",
"cookie-signature": "^1.1.0",
"cross-env": "^7.0.3",
"npm-run-all": "latest",
"playwright": "1.19.2",
"solid-app-router": "^0.4.1",
"solid-js": "^1.4.0",
"solid-meta": "^0.27.3",

@ -1,2 +0,0 @@
DATABASE_URL=file:./dev.db
SESSION_SECRET=hermoin

@ -1,4 +0,0 @@
node_modules
# Keep environment variables out of version control
.env
.db

@ -1,173 +0,0 @@
import { test, expect } from "@playwright/test";
test("basic login test", async ({ browser }) => {
let appURL = new URL(process.env.TEST_HOST ?? "http://localhost:3000/").href;
let javaScriptEnabled = !process.env.DISABLE_JAVASCRIPT;
const context = await browser.newContext({
javaScriptEnabled
});
const page = await context.newPage();
// go to home
await page.goto(appURL);
console.log(`redirect to login page`);
await page.waitForURL(new URL("/login", appURL).href);
console.log("testing wrong password");
await page.fill('input[name="username"]', "kody");
await page.fill('input[name="password"]', "twixroxx");
await page.click("button[type=submit]");
// console.log(page.url())
// console.log(await page.content())
// console.log(page.url())
if (!javaScriptEnabled) {
await page.waitForURL(/Username%2FPassword%20combination%20is%20incorrect%22/);
}
await expect(page.locator("#error-message")).toHaveText(
"Username/Password combination is incorrect"
);
// await page.click("#reset-errors");
console.log("testing wrong username");
await page.fill('input[name="username"]', "kod");
await page.fill('input[name="password"]', "twixrox");
await page.click("button[type=submit]");
if (!javaScriptEnabled) {
await page.waitForURL(/Username%2FPassword%20combination%20is%20incorrect%22/);
}
await expect(page.locator("#error-message")).toHaveText(
"Username/Password combination is incorrect",
{
timeout: 10000
}
);
// await page.click("#reset-errors");
console.log("testing invalid password");
await page.fill('input[name="username"]', "kody");
await page.fill('input[name="password"]', "twix");
await page.click("button[type=submit]");
if (!javaScriptEnabled) {
await page.waitForURL(/Fields%20invalid/);
}
await expect(page.locator("#error-message")).toHaveText("Fields invalid");
// await page.click("#reset-errors");
console.log("login");
await page.fill('input[name="username"]', "kody");
await page.fill('input[name="password"]', "twixrox");
await page.click("button[type=submit]");
console.log(`redirect to home after login`);
await page.waitForURL(appURL);
console.log(`going to login page should redirect to home page since we are logged in`);
await page.goto(new URL("/login", appURL).href);
await page.waitForURL(appURL);
console.log(`logout`);
await page.click("button[name=logout]");
await page.waitForURL(new URL("/login", appURL).href);
console.log(`going to home should redirect to login`);
await page.goto(appURL);
await page.waitForURL(new URL("/login", appURL).href);
});
test("action login test", async ({ browser }) => {
let appURL = new URL(process.env.TEST_HOST ?? "http://localhost:3000/").href + "action";
let javaScriptEnabled = !process.env.DISABLE_JAVASCRIPT;
if (!javaScriptEnabled) {
return;
}
const context = await browser.newContext({
javaScriptEnabled
});
const page = await context.newPage();
// go to home
await page.goto(appURL);
console.log(`redirect to login page`);
await page.waitForURL(new URL("/action/login", appURL).href);
console.log("testing wrong password");
await page.fill('input[name="username"]', "kody");
await page.fill('input[name="password"]', "twixroxx");
await page.click("button[type=submit]");
// console.log(page.url())
// console.log(await page.content())
// console.log(page.url())
if (!javaScriptEnabled) {
await page.waitForURL(/Username%2FPassword%20combination%20is%20incorrect%22/);
}
await expect(page.locator("#error-message")).toHaveText(
"Username/Password combination is incorrect"
);
// await page.click("#reset-errors");
console.log("testing wrong username");
await page.fill('input[name="username"]', "kod");
await page.fill('input[name="password"]', "twixrox");
await page.click("button[type=submit]");
if (!javaScriptEnabled) {
await page.waitForURL(/Username%2FPassword%20combination%20is%20incorrect%22/);
}
await expect(page.locator("#error-message")).toHaveText(
"Username/Password combination is incorrect",
{
timeout: 10000
}
);
// await page.click("#reset-errors");
console.log("testing invalid password");
await page.fill('input[name="username"]', "kody");
await page.fill('input[name="password"]', "twix");
await page.click("button[type=submit]");
if (!javaScriptEnabled) {
await page.waitForURL(/Fields%20invalid/);
}
await expect(page.locator("#error-message")).toHaveText("Fields invalid");
// await page.click("#reset-errors");
console.log("login");
await page.fill('input[name="username"]', "kody");
await page.fill('input[name="password"]', "twixrox");
await page.click("button[type=submit]");
console.log(`redirect to home after login`);
await page.waitForURL(appURL);
console.log(`going to login page should redirect to home page since we are logged in`);
await page.goto(new URL("/action/login", appURL).href);
await page.waitForURL(appURL);
console.log(`logout`);
await page.click("button[name=logout]");
await page.waitForURL(new URL("/action/login", appURL).href);
console.log(`going to home should redirect to login`);
await page.goto(appURL);
await page.waitForURL(new URL("/action/login", appURL).href);
});

@ -1,41 +0,0 @@
{
"name": "example-cloudflare-workers",
"main": "./dist/index.js",
"scripts": {
"dev": "solid-start dev",
"build": "solid-start build",
"start": "solid-start start",
"test": "cross-env PORT=8787 start-server-and-test start http://localhost:8787 test:e2e",
"test:e2e": "cross-env TEST_HOST=http://localhost:$PORT/ npm-run-all -p test:e2e:*",
"test:e2e:js": "playwright test",
"test:e2e:no-js": "cross-env DISABLE_JAVASCRIPT=true playwright test"
},
"type": "module",
"devDependencies": {
"@cloudflare/kv-asset-handler": "^0.1.1",
"@playwright/test": "1.20.0",
"@vitest/ui": "^0.2.7",
"cookie": "^0.4.1",
"cookie-signature": "^1.1.0",
"cross-env": "^7.0.3",
"npm-run-all": "latest",
"playwright": "1.19.2",
"solid-app-router": "^0.4.1",
"solid-js": "^1.4.0",
"solid-meta": "^0.27.3",
"solid-start": "workspace:*",
"solid-start-cloudflare-workers": "next",
"solid-start-node": "workspace:*",
"start-server-and-test": "latest",
"typescript": "^4.4.3",
"undici": "^4.12.2",
"vite": "^2.9.1",
"@cloudflare/wrangler": "latest",
"vite-plugin-windicss": "^1.6.3",
"vitest": "^0.2.7",
"windicss": "^3.4.3"
},
"engines": {
"node": ">=14"
}
}

@ -1,18 +0,0 @@
let users = [{ id: 0, username: "kody", password: "twixrox" }];
export const db = {
user: {
async create({ data }) {
let user = { ...data, id: users.length };
users.push(user);
return user;
},
async findUnique({ where: { username = undefined, id = undefined } }) {
console.log(username, id);
if (id !== undefined) {
return users.find(user => user.id === id);
} else {
return users.find(user => user.username === username);
}
}
}
};

@ -1,100 +0,0 @@
import { redirect } from "solid-start/server";
import { createCookieSessionStorage } from "solid-start/session";
import { db } from ".";
type LoginForm = {
username: string;
password: string;
};
export async function register({ username, password }: LoginForm) {
return db.user.create({
data: { username: username, password }
});
}
export async function login({ username, password }: LoginForm) {
console.log(username, password);
const user = await db.user.findUnique({ where: { username } });
if (!user) return null;
const isCorrectPassword = password === user.password;
if (!isCorrectPassword) return null;
return user;
}
const sessionSecret = import.meta.env.SESSION_SECRET;
// if (!sessionSecret) {
// throw new Error("SESSION_SECRET must be set");
// }
const storage = createCookieSessionStorage({
cookie: {
name: "RJ_session",
// secure doesn't work on localhost for Safari
// https://web.dev/when-to-use-local-https/
secure: true,
secrets: ["hello"],
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true
}
});
export function getUserSession(request: Request) {
return storage.getSession(request.headers.get("Cookie"));
}
export async function getUserId(request: Request) {
const session = await getUserSession(request);
const userId = session.get("userId");
if (!userId || typeof userId !== "string") return null;
return userId;
}
export async function requireUserId(
request: Request,
redirectTo: string = new URL(request.url).pathname
) {
const session = await getUserSession(request);
const userId = session.get("userId");
if (!userId || typeof userId !== "string") {
const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
throw redirect(`/login?${searchParams}`);
}
return userId;
}
export async function getUser(request: Request) {
const userId = await getUserId(request);
if (typeof userId !== "string") {
return null;
}
try {
const user = await db.user.findUnique({ where: { id: Number(userId) } });
return user;
} catch {
throw logout(request);
}
}
export async function logout(request: Request) {
const session = await storage.getSession(request.headers.get("Cookie"));
return redirect("/login", {
headers: {
"Set-Cookie": await storage.destroySession(session)
}
});
}
export async function createUserSession(userId: string, redirectTo: string) {
const session = await storage.getSession();
session.set("userId", userId);
console.log(session);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await storage.commitSession(session)
}
});
}

@ -1,7 +0,0 @@
export default function NotFound() {
return (
<main class="w-full p-4 space-y-2">
<h1 class="font-bold text-xl">Page Not Found</h1>
</main>
);
}

@ -1,31 +0,0 @@
import server, { redirect, createServerResource, createServerAction } from "solid-start/server";
import { createComputed } from "solid-js";
import { getUser, logout } from "~/db/session";
import { useNavigate, useRouteData } from "solid-start/router";
export function routeData() {
return createServerResource(async (_, { request }) => {
if (!(await getUser(request))) {
throw redirect("/action/login");
}
return {};
});
}
export default function Home() {
const data = useRouteData<ReturnType<typeof routeData>>();
createComputed(data);
const navigate = useNavigate();
const logoutAction = createServerAction(() => logout(server.request));
return (
<main class="w-full p-4 space-y-2">
<h1 class="font-bold text-xl">Message board</h1>
<button name="logout" onClick={() => logoutAction.submit().then(() => navigate("/action/login"))}>
Logout
</button>
</main>
);
}

@ -1,151 +0,0 @@
import server, { redirect, createServerAction } from "solid-start/server";
import { db } from "~/db";
import { createUserSession, getUser, login, register } from "~/db/session";
import { useRouteData, useParams, useNavigate, FormError } from "solid-start/router";
import { createResource, Show } from "solid-js";
function validateUsername(username: unknown) {
if (typeof username !== "string" || username.length < 3) {
return `Usernames must be at least 3 characters long`;
}
}
function validatePassword(password: unknown) {
if (typeof password !== "string" || password.length < 6) {
return `Passwords must be at least 6 characters long`;
}
}
export function routeData() {
return createResource(
server(async function () {
if (await getUser(server.request)) {
throw redirect("/action");
}
return {};
})
);
}
export default function Login() {
const [data] = useRouteData<ReturnType<typeof routeData>>();
const navigate = useNavigate();
const loginAction = createServerAction(async (form: FormData) => {
const loginType = form.get("loginType");
const username = form.get("username");
const password = form.get("password");
const redirectTo = form.get("redirectTo") || "/action";
if (
typeof loginType !== "string" ||
typeof username !== "string" ||
typeof password !== "string" ||
typeof redirectTo !== "string"
) {
throw new FormError(`Form not submitted correctly.`);
}
const fields = { loginType, username, password };
const fieldErrors = {
username: validateUsername(username),
password: validatePassword(password)
};
if (Object.values(fieldErrors).some(Boolean)) {
throw new FormError("Fields invalid", { fieldErrors, fields });
}
switch (loginType) {
case "login": {
const user = await login({ username, password });
if (!user) {
throw new FormError(`Username/Password combination is incorrect`, {
fields
});
}
return createUserSession(`${user.id}`, redirectTo);
}
case "register": {
const userExists = await db.user.findUnique({ where: { username } });
if (userExists) {
throw new FormError(`User with username ${username} already exists`, {
fields
});
}
const user = await register({ username, password });
if (!user) {
throw new FormError(`Something went wrong trying to create a new user.`, {
fields
});
}
return createUserSession(`${user.id}`, redirectTo);
}
default: {
throw new FormError(`Login type invalid`, { fields });
}
}
});
const params = useParams();
return (
<div class="p-4">
<div data-light="">
<main class="p-6 mx-auto w-[fit-content] space-y-4 rounded-lg bg-gray-100">
<h1 class="font-bold text-xl">Login</h1>
<form
method="post"
class="flex flex-col space-y-2"
onSubmit={e => {
e.preventDefault();
loginAction.submit(new FormData(e.currentTarget)).then(() => navigate("/action"));
}}
>
<input type="hidden" name="redirectTo" value={params.redirectTo ?? "/action"} />
<fieldset class="flex flex-row">
<legend class="sr-only">Login or Register?</legend>
<label class="w-full">
<input type="radio" name="loginType" value="login" checked={true} /> Login
</label>
<label class="w-full">
<input type="radio" name="loginType" value="register" /> Register
</label>
</fieldset>
<div>
<label for="username-input">Username</label>
<input
name="username"
placeholder="kody"
class="border-gray-700 border-2 ml-2 rounded-md px-2"
/>
<Show when={loginAction.error?.fieldErrors?.username}>
<p class="text-red-400" role="alert">
{loginAction.error.fieldErrors.username}
</p>
</Show>
</div>
<div>
<label for="password-input">Password</label>
<input
name="password"
type="password"
placeholder="twixrox"
class="border-gray-700 border-2 ml-2 rounded-md px-2"
/>
<Show when={loginAction.error?.fieldErrors?.password}>
<p class="text-red-400" role="alert">
{loginAction.error.fieldErrors.password}
</p>
</Show>
</div>
<Show when={loginAction.error}>
<p class="text-red-400" role="alert" id="error-message">
{loginAction.error.message}
</p>
</Show>
<button class="focus:bg-white hover:bg-white bg-gray-300 rounded-md px-2" type="submit">
{data() ? "Login" : ""}
</button>
</form>
</main>
</div>
</div>
);
}

@ -1,32 +0,0 @@
import server, { redirect, createServerResource, createServerAction } from "solid-start/server";
import { useRouteData } from "solid-start/router";
import { getUser, logout } from "~/db/session";
export function routeData() {
return createServerResource(async (_, { request }) => {
const user = await getUser(request);
if (!user) {
throw redirect("/login");
}
return user;
});
}
export default function Home() {
const user = useRouteData<ReturnType<typeof routeData>>();
const logoutAction = createServerAction(() => logout(server.request));
return (
<main class="w-full p-4 space-y-2">
<h1 class="font-bold text-3xl">Hello {user()?.username}</h1>
<h3 class="font-bold text-xl">Message board</h3>
<logoutAction.Form>
<button name="logout" type="submit">
Logout
</button>
</logoutAction.Form>
</main>
);
}

@ -1,142 +0,0 @@
import { Show } from "solid-js";
import { useParams, useRouteData, FormError } from "solid-start/router";
import { redirect, createServerResource, createServerAction } from "solid-start/server";
import { db } from "~/db";
import { createUserSession, getUser, login, register } from "~/db/session";
function validateUsername(username: unknown) {
if (typeof username !== "string" || username.length < 3) {
return `Usernames must be at least 3 characters long`;
}
}
function validatePassword(password: unknown) {
if (typeof password !== "string" || password.length < 6) {
return `Passwords must be at least 6 characters long`;
}
}
export function routeData() {
return createServerResource(async (_, { request }) => {
if (await getUser(request)) {
throw redirect("/");
}
return {};
});
}
export default function Login() {
const data = useRouteData<ReturnType<typeof routeData>>();
const params = useParams();
const loginAction = createServerAction(async (form: FormData) => {
const loginType = form.get("loginType");
const username = form.get("username");
const password = form.get("password");
const redirectTo = form.get("redirectTo") || "/";
if (
typeof loginType !== "string" ||
typeof username !== "string" ||
typeof password !== "string" ||
typeof redirectTo !== "string"
) {
throw new FormError(`Form not submitted correctly.`);
}
const fields = { loginType, username, password };
const fieldErrors = {
username: validateUsername(username),
password: validatePassword(password)
};
if (Object.values(fieldErrors).some(Boolean)) {
throw new FormError("Fields invalid", { fieldErrors, fields });
}
switch (loginType) {
case "login": {
const user = await login({ username, password });
if (!user) {
throw new FormError(`Username/Password combination is incorrect`, {
fields
});
}
return createUserSession(`${user.id}`, redirectTo);
}
case "register": {
const userExists = await db.user.findUnique({ where: { username } });
if (userExists) {
throw new FormError(`User with username ${username} already exists`, {
fields
});
}
const user = await register({ username, password });
if (!user) {
throw new FormError(`Something went wrong trying to create a new user.`, {
fields
});
}
return createUserSession(`${user.id}`, redirectTo);
}
default: {
throw new FormError(`Login type invalid`, { fields });
}
}
});
return (
<div class="p-4">
<div data-light="">
<main class="p-6 mx-auto w-[fit-content] space-y-4 rounded-lg bg-gray-100">
<h1 class="font-bold text-xl">Login</h1>
<loginAction.Form method="post" class="flex flex-col space-y-2">
<input type="hidden" name="redirectTo" value={params.redirectTo ?? "/"} />
<fieldset class="flex flex-row">
<legend class="sr-only">Login or Register?</legend>
<label class="w-full">
<input type="radio" name="loginType" value="login" checked={true} /> Login
</label>
<label class="w-full">
<input type="radio" name="loginType" value="register" /> Register
</label>
</fieldset>
<div>
<label for="username-input">Username</label>
<input
name="username"
placeholder="kody"
class="border-gray-700 border-2 ml-2 rounded-md px-2"
/>
<Show when={loginAction.error?.fieldErrors?.username}>
<p class="text-red-400" role="alert">
{loginAction.error.fieldErrors.username}
</p>
</Show>
</div>
<div>
<label for="password-input">Password</label>
<input
name="password"
type="password"
placeholder="twixrox"
class="border-gray-700 border-2 ml-2 rounded-md px-2"
/>
<Show when={loginAction.error?.fieldErrors?.password}>
<p class="text-red-400" role="alert">
{loginAction.error.fieldErrors.password}
</p>
</Show>
</div>
<Show when={loginAction.error}>
<p class="text-red-400" role="alert" id="error-message">
{loginAction.error.message}
</p>
</Show>
<button class="focus:bg-white hover:bg-white bg-gray-300 rounded-md px-2" type="submit">
{data() ? "Login" : ""}
</button>
</loginAction.Form>
</main>
</div>
</div>
);
}

@ -1,17 +0,0 @@
import { defineConfig } from "vite";
import solid from "solid-start";
import windicss from "vite-plugin-windicss";
export default defineConfig({
plugins: [
windicss(),
solid({
// adapter: "solid-start-node"
adapter: "solid-start-cloudflare-workers"
})
]
// define: {
// "process.env.NODE_ENV": `"production"`,
// "process.env.TEST_ENV": `""`
// }
});

@ -1,13 +0,0 @@
name = "example-with-auth"
type = "javascript"
workers_dev = true
compatibility_date = "2022-03-18"
[site]
bucket = "./dist"
entry-point = "./"
[build]
command = ""
upload.format = "service-worker"

@ -1,32 +0,0 @@
# SolidStart
Everything you need to build a Solid project, powered by [`solid-start`](https://github.com/ryansolid/solid-start/tree/master/packages/solid-start);
## Creating a project
```bash
# create a new project in the current directory
npm init solid@next
# create a new project in my-app
npm init solid@next my-app
```
> Note: the `@next` is temporary
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Solid apps are built with _adapters_, which optimise your project for deployment to different environments.
By default, `npm run build` will generate a Node app that you can run with `node build`. To use a different adapter, add it to the `devDependencies` in `package.json` and specify in your `vite.config.js`.

@ -1,14 +0,0 @@
import { test, expect } from "@playwright/test";
test("basic login test", async ({ browser }) => {
let appURL = new URL(process.env.TEST_HOST ?? "http://localhost:3000/").href;
const context = await browser.newContext({
javaScriptEnabled: !process.env.DISABLE_JAVASCRIPT
});
const page = await context.newPage();
// go to home
await page.goto(appURL);
expect(page.url()).toBe(appURL);
});

@ -1,18 +0,0 @@
import fs from 'fs';
import path from 'path';
const typesPath = path.resolve('node_modules', '@types', 'testing-library__jest-dom', 'index.d.ts');
const refMatcher = /[\r\n]+\/\/\/ <reference types="jest" \/>/;
fs.readFile(typesPath, 'utf8', (err, data) => {
if (err) throw err;
fs.writeFile(
typesPath,
data.replace(refMatcher, ''),
'utf8',
function(err) {
if (err) throw err;
}
);
});

@ -1,45 +0,0 @@
{
"name": "fixture-kitchen-sink",
"scripts": {
"dev": "solid-start dev",
"build": "solid-start build",
"start": "solid-start start",
"postinstall": "node ./fix-jest-dom.mjs",
"test:unit": "npm-run-all test:unit:*",
"test:unit:client": "cross-env TEST_ENV=client TEST_MODE=client vitest run",
"test:unit:client-server": "cross-env TEST_ENV=client TEST_MODE=client-server vitest run",
"test:unit:server": "cross-env TEST_ENV=server TEST_MODE=server vitest run",
"test": "npm run test:unit && start-server-and-test start http://localhost:3000 test:e2e",
"test:e2e": "npm-run-all -p test:e2e:*",
"test:e2e:js": "playwright test e2e",
"test:e2e:no-js": "cross-env DISABLE_JAVASCRIPT=true playwright test e2e"
},
"type": "module",
"devDependencies": {
"@magiql/ide": "^0.0.31",
"@playwright/test": "^1.18.1",
"@testing-library/jest-dom": "^5.16.2",
"@types/testing-library__jest-dom": "^5.14.3",
"cookie": "^0.4.1",
"cookie-signature": "^1.1.0",
"cross-env": "^7.0.3",
"graphql": "^16.3.0",
"graphql-helix": "^1.12.0",
"npm-run-all": "latest",
"playwright": "1.19.2",
"solid-app-router": "^0.4.1",
"solid-js": "^1.4.0",
"solid-meta": "^0.27.3",
"solid-start": "workspace:*",
"solid-start-node": "workspace:*",
"solid-testing-library": "^0.3.0",
"start-server-and-test": "latest",
"typescript": "^4.4.3",
"undici": "^4.12.2",
"vite": "^2.8.6",
"vitest": "^0.6.1"
},
"engines": {
"node": ">=14"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 664 B

@ -1,20 +0,0 @@
.increment {
font-family: inherit;
font-size: inherit;
padding: 1em 2em;
color: #335d92;
background-color: rgba(68, 107, 158, 0.1);
border-radius: 2em;
border: 2px solid rgba(68, 107, 158, 0);
outline: none;
width: 200px;
font-variant-numeric: tabular-nums;
}
.increment:focus {
border: 2px solid #335d92;
}
.increment:active {
background-color: rgba(68, 107, 158, 0.2);
}

@ -1,11 +0,0 @@
import { createSignal } from "solid-js";
import "./Counter.css";
export default function Counter() {
const [count, setCount] = createSignal(0);
return (
<button class="increment" onClick={() => setCount(count() + 1)}>
Clicks: {count}
</button>
);
}

@ -1,4 +0,0 @@
import { hydrate } from "solid-js/web";
import { StartClient } from "solid-start/entry-client";
hydrate(() => <StartClient />, document);

@ -1,3 +0,0 @@
import { StartServer, createHandler, renderAsync } from "solid-start/entry-server";
export default createHandler(renderAsync(context => <StartServer context={context} />));

@ -1,482 +0,0 @@
import { expect, test, assert, vi, it, beforeAll, afterAll, describe } from "vitest";
// Edit an assertion and save to see HMR in action
import "solid-start/runtime/node-globals";
import server, {
handleServerRequest,
XSolidStartContentTypeHeader,
XSolidStartLocationHeader,
XSolidStartResponseTypeHeader
} from "solid-start/server";
import { MockAgent, setGlobalDispatcher } from "undici";
const mockAgent = new MockAgent({});
setGlobalDispatcher(mockAgent);
mockAgent.on("*", console.log);
// Provide the base url to the request
const mockPool = mockAgent.get("http://localhost:3000");
if (process.env.TEST_ENV === "client" && process.env.TEST_MODE === "client-server") {
// tests that the client-server interaction is correct
// wires the client side Request to the server side handler and parses the Response on the way back
let mock = vi.spyOn(server, "fetcher");
mock.mockImplementation(request =>
handleServerRequest({ request, responseHeaders: new Headers(), manifest: {} })
);
}
// tests that the client is sending the correct http request and parsing the http respose correctly,
// mocks fetch
// if testing client-server or server, this is a noop
function mockServerFunction(fn, args, status, response, headers?) {
if (process.env.TEST_MODE === "client") {
mockPool
.intercept({
path: fn.url,
method: "POST"
// body: args ? JSON.stringify(args) : undefined
})
.reply(status, response, { headers });
}
}
it("should handle no args", async () => {
const basic = server(async () => ({ data: "Hello World" }));
mockServerFunction(basic, null, 200, {
data: "Hello World"
});
expect(await basic()).toMatchObject({ data: "Hello World" });
});
it("should handle one string arg", async () => {
const basicArgs = server(async (name?: string) => ({
data: `Hello ${name ?? "World"}`
}));
mockServerFunction(basicArgs, [], 200, {
data: "Hello World"
});
expect(await basicArgs()).toMatchObject({ data: "Hello World" });
mockServerFunction(basicArgs, ["da vinci"], 200, {
data: "Hello da vinci"
});
expect(await basicArgs("da vinci")).toMatchObject({ data: "Hello da vinci" });
});
it("should handle multiple args", async () => {
const mutipleArgs = server(async (name: string, message: string) => ({
data: `${message} ${name}`
}));
mockServerFunction(mutipleArgs, ["World", "Hello"], 200, {
data: "Hello World"
});
expect(await mutipleArgs("World", "Hello")).toMatchObject({ data: "Hello World" });
});
it("should throw object if handler throws", async () => {
const throwJSON = server(async (name?: string) => {
throw {
data: `Hello ${name ?? "World"}`
};
});
mockServerFunction(
throwJSON,
["da vinci"],
200,
{
data: "Hello da vinci"
},
{
[XSolidStartResponseTypeHeader]: "throw",
"content-type": "application/json"
}
);
try {
let e = await throwJSON("da vinci");
throw new Error("should have thrown");
} catch (e) {
expect(e.data).toBe("Hello da vinci");
}
});
it("should allow curried servers with args explicity passed in", async () => {
const curriedServer = (message?: string) => (name?: string) =>
server(async (name?: string, message?: string) => ({
data: `${message ?? "Hello"} ${name ?? "World"}`
}))(name, message);
if (process.env.TEST_MODE === "client") {
return;
}
expect(await curriedServer()()).toMatchObject({ data: "Hello World" });
expect(await curriedServer("Hello")()).toMatchObject({ data: "Hello World" });
expect(await curriedServer("Welcome")()).toMatchObject({ data: "Welcome World" });
expect(await curriedServer("Welcome")("da vinci")).toMatchObject({ data: "Welcome da vinci" });
});
const MESSAGE = "HELLO";
it("should allow access to module scope inside the handler", async () => {
const accessModuleScope = () => (name?: string) =>
server(async (name?: string) => ({
data: `${MESSAGE ?? "Hello"} ${name ?? "World"}`
}))(name);
if (process.env.TEST_MODE === "client") {
return;
}
expect(await accessModuleScope()()).toMatchObject({ data: "HELLO World" });
expect(await accessModuleScope()("World")).toMatchObject({ data: "HELLO World" });
});
it("should throw error when invalid closure", async () => {
const invalidClosureAccess = (message?: string) => (name?: string) =>
server(async (name?: string) => ({
data: `${message ?? "Hello"} ${name ?? "World"}`
}))(name);
if (process.env.TEST_MODE === "client") {
return;
}
try {
expect(await invalidClosureAccess("Welcome")("da vinci")).toMatchObject({
data: "Welcome da vinci"
});
} catch (e) {
assert.equal(
e.message,
`message is not defined\n You probably are using a variable defined in a closure in your server function.`
);
}
});
it("should return redirect when handler returns redirect", async () => {
const redirectServer = server(async () => {
return new Response(null, {
status: 302,
headers: {
Location: "/hello"
}
});
});
mockPool
.intercept({
path: redirectServer.url,
method: "POST",
body: JSON.stringify([])
})
.reply(204, null, {
headers: {
Location: "/hello"
}
});
expect(await redirectServer()).satisfies(e => {
expect(e.headers.get("location")).toBe("/hello");
expect(e.status).toBe(302);
// expect(server.getContext().headers.get("x-solidstart-status-code")).toBe("302");
// expect(server.getContext().headers.get("x-solidstart-location")).toBe("/hello");
// expect(server.getContext().headers.get("location")).toBe("/hello");
return true;
});
});
it("should throw redirect when handler throws redirect", async () => {
const throwRedirectServer = server(async () => {
throw new Response(null, {
status: 302,
headers: {
Location: "/hello"
}
});
});
mockPool
.intercept({
path: throwRedirectServer.url,
method: "POST",
body: JSON.stringify([])
})
.reply(204, null, {
headers: {
Location: "/hello",
[XSolidStartResponseTypeHeader]: "throw"
}
});
try {
await throwRedirectServer();
throw new Error("Should have thrown");
} catch (e) {
expect(e.headers.get("location")).toBe("/hello");
expect(e.status).toBe(302);
// expect(server.getContext().headers.get("x-solidstart-status-code")).toBe("302");
// expect(server.getContext().headers.get("x-solidstart-location")).toBe("/hello");
// expect(server.getContext().headers.get("location")).toBe("/hello");
}
});
it("should return response when handler returns response", async () => {
const redirectServer = server(async () => {
return new Response("text", {
status: 404,
headers: {
RandomHeader: "solidjs"
}
});
});
mockPool
.intercept({
path: redirectServer.url,
method: "POST",
body: JSON.stringify([])
})
.reply(404, "text", {
headers: {
RandomHeader: "solidjs"
}
});
expect(await redirectServer()).satisfies(async e => {
expect(e.headers.get("randomheader")).toBe("solidjs");
expect(e.status).toBe(404);
expect(await e.text()).toBe("text");
return true;
});
});
it("should throw response when handler throws response", async () => {
const throwRedirectServer = server(async () => {
throw new Response("text", {
status: 404,
headers: {
RandomHeader: "solidjs"
}
});
});
mockPool
.intercept({
path: throwRedirectServer.url,
method: "POST",
body: JSON.stringify([])
})
.reply(404, "text", {
headers: {
RandomHeader: "solidjs",
[XSolidStartResponseTypeHeader]: "throw"
}
});
try {
await throwRedirectServer();
throw new Error("Should have thrown");
} catch (e) {
expect(e.headers.get("randomheader")).toBe("solidjs");
expect(e.status).toBe(404);
expect(await e.text()).toBe("text");
}
});
it("should throw error when handler throws error", async () => {
const throwRedirectServer = server(async () => {
let error = new Error("Something went wrong");
error.stack = "Custom stack";
throw error;
});
mockPool
.intercept({
path: throwRedirectServer.url,
method: "POST",
body: JSON.stringify([])
})
.reply(
200,
{
error: {
message: "Something went wrong",
stack: "Custom stack"
}
},
{
headers: {
[XSolidStartContentTypeHeader]: "error",
[XSolidStartResponseTypeHeader]: "throw"
}
}
);
try {
await throwRedirectServer();
throw new Error("Should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(Error);
expect(e.message).toBe("Something went wrong");
expect(e.stack).toBe("Custom stack");
}
});
class FormError extends Error {
constructor(message: string) {
super(message);
this.name = "FormError";
}
}
it("should throw custom error when handler throws error", async () => {
const throwRedirectServer = server(async () => {
let error = new FormError("Something went wrong");
error.stack = "Custom stack";
throw error;
});
mockPool
.intercept({
path: throwRedirectServer.url,
method: "POST",
body: JSON.stringify([])
})
.reply(
200,
{
error: {
message: "Something went wrong",
stack: "Custom stack",
name: "FormError"
}
},
{
headers: {
[XSolidStartContentTypeHeader]: "error",
[XSolidStartResponseTypeHeader]: "throw"
}
}
);
try {
await throwRedirectServer();
throw new Error("Should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(Error);
expect(e.message).toBe("Something went wrong");
expect(e.stack).toBe("Custom stack");
}
});
it("should send request when caller sends request", async () => {
const requestServer = server(async request => {
return { data: request.headers.get("x-test") };
});
mockPool
.intercept({
path: requestServer.url,
method: "POST",
body: JSON.stringify([
{
$type: "request",
url: "http://localhost:3000/",
method: "GET",
headers: {
$type: "headers",
values: [["x-test", "test"]]
}
}
])
})
.reply(200, {
data: "test"
});
expect(
await requestServer(new Request("http://localhost:3000/", { headers: { "x-test": "test" } }))
).toMatchObject({
data: "test"
});
});
it("should send request inside an object when caller sends context", async () => {
const requestServer = server(async ({ request }) => {
return { data: request.headers.get("x-test") };
});
mockPool
.intercept({
path: requestServer.url,
method: "POST",
body: JSON.stringify([
{
request: {
$type: "request",
url: "http://localhost:3000/",
method: "GET",
headers: {
$type: "headers",
values: [["x-test", "test"]]
}
}
}
])
})
.reply(200, {
data: "test"
});
expect(
await requestServer({
request: new Request("http://localhost:3000/", { headers: { "x-test": "test" } })
})
).toMatchObject({
data: "test"
});
});
it("should send headers inside an object when caller sends object with headers", async () => {
const requestServer = server(async ({ headers }) => {
return { data: headers.get("x-test") };
});
mockPool
.intercept({
path: requestServer.url,
method: "POST",
body: JSON.stringify([
{
headers: {
$type: "headers",
values: [["x-test", "test"]]
}
}
])
})
.reply(200, {
data: "test"
});
expect(
await requestServer({
headers: new Headers({ "x-test": "test" })
})
).toMatchObject({
data: "test"
});
});

@ -1,22 +0,0 @@
// @refresh reload
import { Links, Meta, Routes, Scripts } from "solid-start/root";
import { ErrorBoundary } from "solid-start/error-boundary";
export default function Root() {
return (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<ErrorBoundary>
<Routes />
</ErrorBoundary>
<Scripts />
</body>
</html>
);
}

@ -1,17 +0,0 @@
import Counter from "~/components/Counter";
import { Link } from "solid-app-router";
export default function NotFound() {
return (
<main>
<h1>Page Not Found</h1>
<p>
Visit{" "}
<a href="https://solidjs.com" target="_blank">
solidjs.com
</a>{" "}
to learn how to build Solid apps.
</p>
</main>
);
}

@ -1,53 +0,0 @@
import { buildSchema } from "graphql";
import { processRequest } from "graphql-helix";
import { RequestContext } from "solid-start/entry-server";
import { json } from "solid-start/server";
import renderPlayground from "@magiql/ide/render";
const graphQLServer = async (schema, query, vars, request) => {
try {
const response = await processRequest({
query,
variables: vars ?? {},
request,
schema
});
switch (response.type) {
case "RESPONSE": {
return response.payload;
}
default: {
throw new Error("Hello world");
}
}
} catch (e) {
console.log(e);
}
};
const schema = buildSchema(`
type Query {
hello: String
}
`);
// GraphQL API endpoint
export async function post({ request }: RequestContext) {
const { query, variables } = await request.json();
return json(await graphQLServer(schema, query, variables, request));
}
// render GraphiQL playground
export async function get({ request }: RequestContext) {
return new Response(
renderPlayground({
uri: "/api/graphql"
}),
{
headers: {
"Content-Type": "text/html"
}
}
);
}

@ -1,14 +0,0 @@
import { RequestContext } from "solid-start/entry-server";
import { ContentTypeHeader, json } from "solid-start/server";
export async function get({ request }: RequestContext) {
const url = new URL(request.url);
const response = await fetch(
`https://assets.solidjs.com/banner?project=${url.searchParams.get("project")}&type=core`
);
return new Response(response.body, {
headers: {
[ContentTypeHeader]: "image/svg+xml"
}
});
}

@ -1,36 +0,0 @@
body {
font-family: Gordita, Roboto, Oxygen, Ubuntu, Cantarell,
'Open Sans', 'Helvetica Neue', sans-serif;
}
main {
text-align: center;
padding: 1em;
margin: 0 auto;
}
h1 {
color: #335d92;
text-transform: uppercase;
font-size: 4rem;
font-weight: 100;
line-height: 1.1;
margin: 4rem auto;
max-width: 14rem;
}
p {
max-width: 14rem;
margin: 2rem auto;
line-height: 1.35;
}
@media (min-width: 480px) {
h1 {
max-width: none;
}
p {
max-width: none;
}
}

@ -1,18 +0,0 @@
import Counter from "~/components/Counter";
import "./index.css";
export default function Home() {
return (
<main>
<h1>Hello world!</h1>
<Counter />
<p>
Visit{" "}
<a href="https://solidjs.com" target="_blank">
solidjs.com
</a>{" "}
to learn how to build Solid apps.
</p>
</main>
);
}

@ -1,16 +0,0 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"jsxImportSource": "solid-js",
"jsx": "preserve",
"types": ["vite/client"],
"baseUrl": "./",
"paths": {
"~/*": ["./src/*"]
}
}
}

@ -1,36 +0,0 @@
import { defineConfig } from "vite";
import solid from "solid-start";
export default defineConfig({
test: {
exclude: ["e2e", "node_modules"],
// globals: true,
environment: "jsdom",
transformMode:
process.env.TEST_ENV === "server"
? {
ssr: [/.[tj]sx?$/]
}
: {
web: [/.[tj]sx?$/]
},
// solid needs to be inline to work around
// a resolution issue in vitest:
deps: {
inline: [/solid-js/]
}
// if you have few tests, try commenting one
// or both out to improve performance:
// threads: false,
// isolate: false,
},
build: {
target: "esnext",
polyfillDynamicImport: false
},
resolve: {
conditions: process.env.TEST_ENV === "server" ? [] : ["development", "browser"]
},
plugins: [solid()]
});

@ -3,6 +3,7 @@
"description": "Official starter for SolidJS",
"version": "0.1.0",
"author": "Ryan Carniato",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
@ -14,12 +15,10 @@
"clean:artifacts": "lerna run clean --parallel",
"clean:packages": "lerna clean --yes",
"clean:root": "rimraf node_modules",
"test": "pnpm run test -r --parallel --if-present",
"test:coverage": "lerna run test:coverage --parallel",
"build": "pnpm run build -r --parallel --if-present",
"publish:release": "pnpm run build && lerna publish",
"docs:dev": "pnpm run dev --filter solid-start-docs",
"docs:build": "pnpm run build --filter solid-start-docs"
"publish:release": "lerna publish",
"docs:dev": "pnpm --filter solid-start-docs run dev",
"docs:build": "pnpm --filter solid-start-docs run build",
"test": "pnpm --filter solid-start-tests test"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^19.0.1",

@ -62,8 +62,8 @@ export default function () {
// closes the bundle
await bundle.close();
unlinkSync(join(config.root, "dist", "public", "manifest.json"));
unlinkSync(join(config.root, "dist", "public", "rmanifest.json"));
// unlinkSync(join(config.root, "dist", "public", "manifest.json"));
// unlinkSync(join(config.root, "dist", "public", "rmanifest.json"));
}
};
}

@ -1,6 +1,7 @@
export * from "./responses";
export * from "./StartContext";
export * from "./types";
export { StatusCode } from "./StatusCode";
export { HttpHeader } from "./HttpHeader";
export { createServerResource, createServerAction } from "./resource";

@ -21,10 +21,10 @@ type RouterContext = {
export interface RequestContext {
request: Request;
responseHeaders: Headers;
manifest?: Record<string, ManifestEntry[]>;
}
export interface PageContext extends RequestContext {
manifest: Record<string, ManifestEntry[]>;
routerContext?: RouterContext;
tags?: TagDescription[];
setStatusCode(code: number): void;

File diff suppressed because it is too large Load Diff

@ -2,8 +2,9 @@ packages:
# all packages in subdirs of packages/ and examples/
- 'packages/**'
- 'examples/**'
- 'fixtures/**'
- 'docs/**'
- 'test/**'
- '!**/.tmp/**'
options:
prefer-workspace-packages: true
strict-peer-dependencies: false

@ -0,0 +1,52 @@
import { test, expect } from "@playwright/test";
import { createFixture, createAppFixture, js } from "./helpers/create-fixture.js";
import type { AppFixture, Fixture } from "./helpers/create-fixture.js";
import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
test.describe("loader", () => {
let fixture: Fixture;
let appFixture: AppFixture;
test.beforeAll(async () => {
fixture = await createFixture({
files: {
"src/routes/index.tsx": js`
export default function Index() {
return <div id="text">Hello World</div>
}
`
}
});
appFixture = await createAppFixture(fixture);
});
test.afterAll(async () => {
await appFixture.close();
});
let logs: string[] = [];
test.beforeEach(({ page }) => {
page.on("console", msg => {
logs.push(msg.text());
});
});
test.afterEach(() => {
expect(logs).toHaveLength(0);
});
test("returns responses for a specific route", async () => {
let root = await fixture.requestDocument("/");
expect(root.headers.get("Content-Type")).toBe("text/html");
});
test("is called on script transition POST requests", async ({ page }) => {
let app = new PlaywrightFixture(appFixture, page);
await app.goto(`/`);
let html = await app.getHtml("#text");
expect(html).toMatch("Hello World");
});
});

@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
import { createFixture, js } from "./helpers/create-fixture.js";
import type { Fixture } from "./helpers/create-fixture.js";
test.describe("loader", () => {
let fixture: Fixture;
test.beforeAll(async () => {
fixture = await createFixture({
files: {
"src/routes/index.tsx": js`
export default function Index() {
return <div>Hello World</div>;
}
`
}
});
});
test("returns responses for a specific route", async () => {
let root = await fixture.requestDocument("/");
expect(root.headers.get("Content-Type")).toBe("text/html");
});
});

@ -0,0 +1,256 @@
import path from "path";
import fse from "fs-extra";
import type { Writable } from "stream";
// import express from "express";
import getPort from "get-port";
import stripIndent from "strip-indent";
import c from "picocolors";
import { fileURLToPath } from "url";
import { spawn, sync as spawnSync } from "cross-spawn";
import { Readable } from "stream";
import fs from "fs";
import polka from "polka";
import { dirname, join } from "path";
import sirv from "sirv";
import { once } from "events";
import "solid-start/runtime/node-globals.js";
import { createRequest } from "solid-start/runtime/fetch.js";
import prepareManifest from "solid-start/runtime/prepareManifest.js";
import type { RequestContext } from "solid-start/server/types.js";
const TMP_DIR = path.join(
path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url)))),
".tmp"
);
interface FixtureInit {
buildStdio?: Writable;
sourcemap?: boolean;
files: { [filename: string]: string };
template?: "cf-template" | "deno-template" | "node-template";
setup?: "node" | "cloudflare";
}
interface EntryServer {
default: (request: RequestContext) => Promise<Response>;
}
export type Fixture = Awaited<ReturnType<typeof createFixture>>;
export type AppFixture = Awaited<ReturnType<typeof createAppFixture>>;
export const js = String.raw;
export const mdx = String.raw;
export const css = String.raw;
export function json(value: object) {
return JSON.stringify(value, null, 2);
}
export async function createFixture(init: FixtureInit) {
let projectDir = await createFixtureProject(init);
let buildPath = path.resolve(projectDir, ".solid", "server", "entry-server.js");
if (!fse.existsSync(buildPath)) {
throw new Error(
c.red(
`Expected build directory to exist at ${c.dim(
buildPath
)}. The build probably failed. Did you maybe have a syntax error in your test code strings?`
)
);
}
let app: EntryServer = await import(buildPath);
let manifest = fse.readJSONSync(path.resolve(projectDir, "dist", "public", "rmanifest.json"));
let assetManifest = fse.readJSONSync(path.resolve(projectDir, "dist", "public", "manifest.json"));
prepareManifest(manifest, assetManifest);
let handler = async (request: Request) => {
return await app.default({
request: request,
responseHeaders: new Headers(),
manifest
});
};
let requestDocument = async (href: string, init?: RequestInit) => {
let url = new URL(href, "test://test");
let request = new Request(url, init);
return await handler(request);
};
let requestData = async (href: string, routeId: string, init?: RequestInit) => {
let url = new URL(href, "test://test");
url.searchParams.set("_data", routeId);
let request = new Request(url, init);
return await handler(request);
};
let postDocument = async (href: string, data: URLSearchParams | FormData) => {
return await requestDocument(href, {
method: "POST",
body: data,
headers: {
"Content-Type":
data instanceof URLSearchParams
? "application/x-www-form-urlencoded"
: "multipart/form-data"
}
});
};
let getBrowserAsset = async (asset: string) => {
return fse.readFile(path.join(projectDir, "public", asset.replace(/^\//, "")), "utf8");
};
return {
projectDir,
build: app,
requestDocument,
requestData,
postDocument,
getBrowserAsset,
manifest
};
}
export async function createAppFixture(fixture: Fixture) {
let startAppServer = async (): Promise<{
port: number;
stop: () => Promise<void>;
}> => {
return new Promise(async (accept, reject) => {
let port = await getPort();
const noop_handler = (_req, _res, next) => next();
const paths = {
assets: path.join(fixture.projectDir, "dist", "public")
};
const assets_handler = fs.existsSync(paths.assets)
? sirv(paths.assets, {
maxAge: 31536000,
immutable: true
})
: noop_handler;
const render = async (req, res, next) => {
if (req.url === "/favicon.ico") return;
const webRes = await fixture.build.default({
request: createRequest(req),
responseHeaders: new Headers(),
manifest: fixture.manifest
});
res.statusCode = webRes.status;
res.statusMessage = webRes.statusText;
for (const [name, value] of webRes.headers) {
res.setHeader(name, value);
}
if (webRes.body) {
const readable = Readable.from(webRes.body as any);
readable.pipe(res);
await once(readable, "end");
} else {
res.end();
}
};
const app = polka().use("/", assets_handler).use(render);
// app.all(
// "*",
// createExpressHandler({ build: fixture.build, mode: "production" })
// );
let stop = (): Promise<void> => {
return new Promise((res, rej) => {
app.server.close(err => {
if (err) {
rej(err);
} else {
res();
}
});
});
};
app.listen(port, () => {
accept({ stop, port });
});
});
};
let start = async () => {
let { stop, port } = await startAppServer();
let serverUrl = `http://localhost:${port}`;
return {
serverUrl,
/**
* Shuts down the fixture app, **you need to call this
* at the end of a test** or `afterAll` if the fixture is initialized in a
* `beforeAll` block. Also make sure to `await app.close()` or else you'll
* have memory leaks.
*/
close: async () => {
return stop();
}
};
};
return start();
}
////////////////////////////////////////////////////////////////////////////////
export async function createFixtureProject(init: FixtureInit): Promise<string> {
let template = init.template ?? "node-template";
let dirname = path.dirname(path.dirname(new URL(import.meta.url).pathname));
let integrationTemplateDir = path.join(dirname, template);
let projectName = `remix-${template}-${Math.random().toString(32).slice(2)}`;
let projectDir = path.join(TMP_DIR, projectName);
await fse.ensureDir(projectDir);
await fse.copy(integrationTemplateDir, projectDir);
// await fse.copy(
// path.join(dirname, "../../build/node_modules"),
// path.join(projectDir, "node_modules"),
// { overwrite: true }
// );
if (init.setup) {
spawnSync("node", ["node_modules/@remix-run/dev/cli.js", "setup", init.setup], {
cwd: projectDir
});
}
await writeTestFiles(init, projectDir);
await build(projectDir, init.buildStdio, init.sourcemap);
return projectDir;
}
async function build(projectDir: string, buildStdio?: Writable, sourcemap?: boolean) {
// let buildArgs = ["node_modules/@remix-run/dev/cli.js", "build"];
// if (sourcemap) {
// buildArgs.push("--sourcemap");
// }
let proc = spawnSync("node", ["node_modules/solid-start/bin.cjs", "build"], {
cwd: projectDir
});
console.log(proc.stdout.toString());
console.error(proc.stderr.toString());
}
async function writeTestFiles(init: FixtureInit, dir: string) {
await Promise.all(
Object.keys(init.files).map(async filename => {
let filePath = path.join(dir, filename);
await fse.ensureDir(path.dirname(filePath));
await fse.writeFile(filePath, stripIndent(init.files[filename]));
})
);
}

@ -0,0 +1,309 @@
import cp from "child_process";
import type { Page, Response, Request } from "@playwright/test";
import { test } from "@playwright/test";
import cheerio from "cheerio";
import prettier from "prettier";
import type { AppFixture } from "./create-fixture.js";
export class PlaywrightFixture {
readonly page: Page;
readonly app: AppFixture;
constructor(app: AppFixture, page: Page) {
this.page = page;
this.app = app;
}
/**
* Visits the href with a document request.
*
* @param href The href you want to visit
* @param waitForHydration Will wait for the network to be idle, so
* everything should be loaded and ready to go
*/
async goto(href: string, waitForHydration?: true) {
return this.page.goto(this.app.serverUrl + href, {
waitUntil: waitForHydration ? "networkidle" : undefined,
});
}
/**
* Finds a link on the page with a matching href, clicks it, and waits for
* the network to be idle before continuing.
*
* @param href The href of the link you want to click
* @param options `{ wait }` waits for the network to be idle before moving on
*/
async clickLink(href: string, options: { wait: boolean } = { wait: true }) {
let selector = `a[href="${href}"]`;
let el = await this.page.$(selector);
if (!el) {
throw new Error(`Could not find link for ${selector}`);
}
if (options.wait) {
await doAndWait(this.page, () => el!.click());
} else {
await el.click();
}
}
/**
* Find the input element and fill for file uploads.
*
* @param inputSelector The selector of the input you want to fill
* @param filePaths The paths to the files you want to upload
*/
async uploadFile(inputSelector: string, ...filePaths: string[]) {
let el = await this.page.$(inputSelector);
if (!el) {
throw new Error(`Could not find input for: ${inputSelector}`);
}
await el.setInputFiles(filePaths);
}
/**
* Finds the first submit button with `formAction` that matches the
* `action` supplied, clicks it, and optionally waits for the network to
* be idle before continuing.
*
* @param action The formAction of the button you want to click
* @param options `{ wait }` waits for the network to be idle before moving on
*/
async clickSubmitButton(
action: string,
options: { wait?: boolean; method?: string } = { wait: true }
) {
let selector: string;
if (options.method) {
selector = `button[formAction="${action}"][formMethod="${options.method}"]`;
} else {
selector = `button[formAction="${action}"]`;
}
let el = await this.page.$(selector);
if (!el) {
if (options.method) {
selector = `form[action="${action}"] button[type="submit"][formMethod="${options.method}"]`;
} else {
selector = `form[action="${action}"] button[type="submit"]`;
}
el = await this.page.$(selector);
if (!el) {
throw new Error(`Can't find button for: ${action}`);
}
}
if (options.wait) {
await doAndWait(this.page, () => el.click());
} else {
await el.click();
}
}
/**
* Clicks any element and waits for the network to be idle.
*/
async clickElement(selector: string) {
let el = await this.page.$(selector);
if (!el) {
throw new Error(`Can't find element for: ${selector}`);
}
await doAndWait(this.page, () => el.click());
}
/**
* Perform any interaction and wait for the network to be idle:
*
* ```js
* await app.waitForNetworkAfter(page, () => app.page.focus("#el"))
* ```
*/
async waitForNetworkAfter(fn: () => Promise<unknown>) {
await doAndWait(this.page, fn);
}
/**
* "Clicks" the back button and optionally waits for the network to be
* idle (defaults to waiting).
*/
async goBack(options: { wait: boolean } = { wait: true }) {
if (options.wait) {
await doAndWait(this.page, () => this.page.goBack());
} else {
await this.page.goBack();
}
}
/**
* Collects data responses from the network, usually after a link click or
* form submission. This is useful for asserting that specific loaders
* were called (or not).
*/
collectDataResponses() {
return collectDataResponses(this.page);
}
/**
* Collects all responses from the network, usually after a link click or
* form submission. A filter can be provided to only collect responses
* that meet a certain criteria.
*/
collectResponses(filter?: UrlFilter) {
return collectResponses(this.page, filter);
}
/**
* Get HTML from the page. Useful for asserting something rendered that
* you expected.
*
* @param selector CSS Selector for the element's HTML you want
*/
getHtml(selector?: string) {
return getHtml(this.page, selector);
}
/**
* Get a cheerio instance of an element from the page.
*
* @param selector CSS Selector for the element's HTML you want
*/
async getElement(selector?: string) {
return getElement(await getHtml(this.page), selector);
}
/**
* Keeps the fixture running for as many seconds as you want so you can go
* poke around in the browser to see what's up.
*
* @param seconds How long you want the app to stay open
*/
async poke(seconds: number = 10, href: string = "/") {
let ms = seconds * 1000;
test.setTimeout(ms);
console.log(
`🙈 Poke around for ${seconds} seconds 👉 ${this.app.serverUrl}`
);
cp.exec(`open ${this.app.serverUrl}${href}`);
return new Promise((res) => setTimeout(res, ms));
}
}
export async function getHtml(page: Page, selector?: string) {
let html = await page.content();
return selector ? selectHtml(html, selector) : prettyHtml(html);
}
export function getElement(source: string, selector: string) {
let el = cheerio(selector, source);
if (!el.length) {
throw new Error(`No element matches selector "${selector}"`);
}
return el;
}
export function selectHtml(source: string, selector: string) {
let el = getElement(source, selector);
return prettyHtml(cheerio.html(el)).trim();
}
export function prettyHtml(source: string): string {
return prettier.format(source, { parser: "html" });
}
async function doAndWait(
page: Page,
action: () => Promise<unknown>,
longPolls = 0
) {
let DEBUG = !!process.env.DEBUG;
let networkSettledCallback: any;
let networkSettledPromise = new Promise((resolve) => {
networkSettledCallback = resolve;
});
let requestCounter = 0;
let actionDone = false;
let pending = new Set<Request>();
let maybeSettle = () => {
if (actionDone && requestCounter <= longPolls) networkSettledCallback();
};
let onRequest = (request: Request) => {
++requestCounter;
if (DEBUG) {
pending.add(request);
console.log(`+[${requestCounter}]: ${request.url()}`);
}
};
let onRequestDone = (request: Request) => {
// Let the page handle responses asynchronously (via setTimeout(0)).
//
// Note: this might be changed to use delay, e.g. setTimeout(f, 100),
// when the page uses delay itself.
let evaluate = page.evaluate(() => {
return new Promise((resolve) => setTimeout(resolve, 0));
});
evaluate
.catch(() => null)
.then(() => {
--requestCounter;
maybeSettle();
if (DEBUG) {
pending.delete(request);
console.log(`-[${requestCounter}]: ${request.url()}`);
}
});
};
page.on("request", onRequest);
page.on("requestfinished", onRequestDone);
page.on("requestfailed", onRequestDone);
let timeoutId: NodeJS.Timer;
if (DEBUG) {
timeoutId = setInterval(() => {
console.log(`${requestCounter} requests pending:`);
for (let request of pending) console.log(` ${request.url()}`);
}, 5000);
}
let result = await action();
actionDone = true;
maybeSettle();
if (DEBUG) {
console.log(`action done, ${requestCounter} requests pending`);
}
await networkSettledPromise;
if (DEBUG) {
console.log(`action done, network settled`);
}
page.removeListener("request", onRequest);
page.removeListener("requestfinished", onRequestDone);
page.removeListener("requestfailed", onRequestDone);
if (DEBUG) {
clearTimeout(timeoutId);
}
return result;
}
type UrlFilter = (url: URL) => boolean;
function collectResponses(page: Page, filter?: UrlFilter): Response[] {
let responses: Response[] = [];
page.on("response", (res) => {
if (!filter || filter(new URL(res.url()))) {
responses.push(res);
}
});
return responses;
}
function collectDataResponses(page: Page) {
return collectResponses(page, (url) => url.searchParams.has("_data"));
}

@ -0,0 +1,22 @@
{
"name": "example-app-ts",
"scripts": {
"dev": "solid-start dev",
"build": "solid-start build",
"start": "solid-start start"
},
"type": "module",
"devDependencies": {
"solid-app-router": "^0.4.1",
"solid-js": "^1.4.0",
"solid-meta": "^0.27.3",
"solid-start": "workspace:*",
"solid-start-node": "workspace:*",
"typescript": "^4.4.3",
"undici": "^4.12.2",
"vite": "^2.8.6"
},
"engines": {
"node": ">=14"
}
}

Before

Width:  |  Height:  |  Size: 664 B

After

Width:  |  Height:  |  Size: 664 B

@ -1,8 +1,7 @@
// @refresh reload
import { Suspense } from "solid-js";
import { Links, Meta, Routes, Scripts } from "solid-start/root";
import { ErrorBoundary } from "solid-start/error-boundary";
import "virtual:windi.css";
import { Suspense } from "solid-js";
export default function Root() {
return (
@ -15,7 +14,7 @@ export default function Root() {
</head>
<body>
<ErrorBoundary>
<Suspense fallback={<div>Loading</div>}>
<Suspense>
<Routes />
</Suspense>
</ErrorBoundary>

@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import solid from "solid-start";
export default defineConfig({
plugins: [solid()]
});

@ -0,0 +1,28 @@
{
"name": "solid-start-tests",
"type": "module",
"scripts": {
"clean": "rimraf .tmp",
"install:playwright": "playwright install",
"test": "playwright test --config ./playwright.config.ts"
},
"dependencies": {
"@playwright/test": "^1.22.2",
"@types/cross-spawn": "^6.0.2",
"@types/fs-extra": "^9.0.13",
"@types/node": "^18.0.0",
"cheerio": "1.0.0-rc.11",
"compression": "^1.7.4",
"cross-spawn": "^7.0.3",
"fs-extra": "^10.1.0",
"get-port": "^6.1.2",
"picocolors": "^1.0.0",
"polka": "^1.0.0-next.13",
"prettier": "^2.5.1",
"sirv": "^2.0.2",
"solid-start": "workspace:*",
"solid-start-node": "workspace:*",
"strip-indent": "^4.0.0",
"undici": "^4.12.2"
}
}

@ -0,0 +1,29 @@
import type { PlaywrightTestConfig } from "@playwright/test";
import { devices } from "@playwright/test";
const config: PlaywrightTestConfig = {
testDir: ".",
testMatch: ["**/*-test.ts"],
/* Maximum time one test can run for. */
timeout: 30_000,
expect: {
/* Maximum time expect() should wait for the condition to be met. */
timeout: 5_000
},
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? "github" : [["html", { open: "never" }]],
use: { actionTimeout: 0 },
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"]
}
}
]
};
export default config;
Loading…
Cancel
Save