test: playwright based integration tests
parent
d637b5c1d3
commit
33a5dd27df
@ -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);
|
||||
});
|
@ -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()]
|
||||
});
|
@ -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";
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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 |
@ -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…
Reference in New Issue