Create Todo app

Summary: Ref T2687

Reviewers: #testers, kuba-orlik

Reviewed By: #testers, kuba-orlik

Subscribers: kuba-orlik, jenkins-user

Maniphest Tasks: T2687

Differential Revision: https://hub.sealcode.org/D1339
master
Luryxyt 6 months ago committed by Kuba Orlik
parent b7cc271d97
commit 0c49effa9d

3
.gitignore vendored

@ -6,6 +6,7 @@ tmp/
*.sublime-workspace
npm-debug.log
node_modules
docker_node_modules
db
coverage.html
\#*
@ -27,3 +28,5 @@ public/dist
/hint-report/
.vscode
.env
.npm_cache/
.mozilla/

@ -11,8 +11,9 @@ services:
dockerfile: ./test.Dockerfile
volumes:
- ./:/opt/sealious-app/
- ~/.npm_cacache:/opt/sealious-app/.npm_cacache
user: ${UID:-1000}:${GID:-1000}
- /tmp:/tmp
- ./docker_node_modules:/opt/sealious-app/node_modules
user: ${USER_ID:-1000}:${GROUP_ID:-1000}
mailcatcher:
image: schickling/mailcatcher:latest
ports:

@ -10,13 +10,16 @@ RUN apt update
RUN apt install -y git
RUN apt install -y tmux
# playwright deps
RUN apt-get update&& apt-get install -y --no-install-recommends libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0 libnspr4 libnss3 libpango-1.0-0 libwayland-client0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 xvfb fonts-noto-color-emoji fonts-unifont libfontconfig1 libfreetype6 xfonts-cyrillic xfonts-scalable fonts-liberation fonts-ipafont-gothic fonts-wqy-zenhei fonts-tlwg-loma-otf fonts-freefont-ttf libcairo-gobject2 libdbus-glib-1-2 libgdk-pixbuf-2.0-0 libgtk-3-0 libharfbuzz0b libpangocairo-1.0-0 libx11-xcb1 libxcb-shm0 libxcursor1 libxi6 libxrender1 libxtst6 gstreamer1.0-libav gstreamer1.0-plugins-bad gstreamer1.0-plugins-base gstreamer1.0-plugins-good libegl1 libenchant-2-2 libepoxy0 libevdev2 libgles2 libglx0 libgstreamer-gl1.0-0 libgstreamer-plugins-base1.0-0 libgstreamer1.0-0 libgudev-1.0-0 libharfbuzz-icu0 libhyphen0 libicu67 libjpeg62-turbo liblcms2-2 libmanette-0.2-0 libnotify4 libopengl0 libopenjp2-7 libopus0 libpng16-16 libproxy1v5 libsecret-1-0 libsoup2.4-1 libwayland-egl1 libwayland-server0 libwebp6 libwebpdemux2 libwoff1 libxml2 libxslt1.1 libatomic1 libevent-2.1-7
RUN chmod +x /tini
ENTRYPOINT ["/tini", "--"]
VOLUME $HOME
WORKDIR $HOME
RUN npm install -g npm@latest
RUN npm install -g npm@7
RUN npm install -g @sealcode/sealgen
USER $UID:$GID

@ -3,6 +3,7 @@ set -e
docker-compose down
npm install @sealcode/sealgen
npx sealgen make-env
cp secrets.example.json secrets.json
@ -11,17 +12,13 @@ SEALIOUS_BASE_URL=$(cat .base_url)
export SEALIOUS_BASE_URL
echo "PORT=$PORT" >> .env
# Create the npm cache directory if it isn't present yet. If it is not present, it will be created
# when the docker image is being built with root:root as the owner.
mkdir -p ~/.npm_cacache
mkdir -p node_modules
mkdir -p docker_node_modules
# https://github.com/docker/compose/issues/4725
docker-compose build
# Create .npm directory in the container, since it is not yet present and we need it for next step.
docker-compose run --user="$UID" --rm --service-ports test mkdir -p /opt/sealious-app/.npm
# Link the host-bound npm cache directory into the container's npm cache directory.
docker-compose run --user="$UID" --rm --service-ports test ln -s /opt/sealious-app/.npm_cacache/ /opt/sealious-app/.npm/_cacache
docker-compose up -d db
./npm.sh --no-TTY --user="$UID" ci && ./npm.sh --no-TTY --user="$UID" run build
./npm.sh --no-TTY ci && ./npm.sh --no-TTY run build
rm -f log.html

@ -1,5 +1,5 @@
#!/bin/bash -xe
echo "starting sanity test..."
export SEALIOUS_PORT=$PORT
SEALIOUS_BASE_URL=$(cat .base_url)
export SEALIOUS_BASE_URL
@ -14,4 +14,3 @@ docker-compose run --user="$UID" \
-e "SEALIOUS_BASE_URL=$SEALIOUS_BASE_URL" \
-e "SEALIOUS_SANITY=true" \
test

@ -0,0 +1,9 @@
import { LONG_TEST_TIMEOUT } from "./src/back/test_utils/webhint";
import { closeBrowser } from "./src/back/test_utils/browser-creator";
exports.mochaHooks = {
async afterAll() {
this.timeout(LONG_TEST_TIMEOUT);
await closeBrowser();
},
};

@ -1,12 +1,14 @@
#!/usr/bin/env -S bash -x
# the "--no-TTY" option is crucial - without it the output is not captured in Jenkins
docker-compose run \
--rm \
--service-ports \
-e "SEALIOUS_MONGO_PORT=27017" \
-e "SEALIOUS_MONGO_HOST=db" \
test \
npm "$@"
CONTAINER_ID=$(docker-compose run \
-d \
--service-ports \
-e "SEALIOUS_MONGO_PORT=27017" \
-e "SEALIOUS_MONGO_HOST=db" \
test \
npm "$@")
docker logs -f $CONTAINER_ID
docker rm $CONTAINER_ID

13801
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -5,29 +5,41 @@
"main": "./dist/back/index.js",
"scripts": {
"start": "docker-compose up -d db && node .",
"typecheck:back": "tsc --noEmit -p src/back",
"typecheck:front": "tsc --noEmit -p src/front",
"typecheck:back": "npx tsc --noEmit --target es6 --lib es2015,dom -p src/back",
"typecheck:front": "npx tsc --noEmit --target es6 --lib es2015,dom -p src/front",
"build": "sealgen build",
"watch": "multiple-scripts-tmux \"npm run typecheck:back -- --watch\" \"SEALIOUS_PORT=$SEALIOUS_PORT SEALIOUS_BASE_URL=$SEALIOUS_BASE_URL nodemon --enable-source-maps .\" \"npm run build -- --watch\" \"npm run typecheck:front -- --watch\" ",
"test": "TS_NODE_PROJECT='./src/back/tsconfig.json' mocha --recursive --require ts-node/register src/back/app.ts src/back/**/*.test.ts src/back/**/**/*.test.ts src/back/**/**/**/*.test.ts src/back/**/**/**/**/*.test.ts",
"watch": "multiple-scripts-tmux -p watch",
"reset-db": "docker-compose down && docker-compose up -d",
"install-test-deps": "npx playwright install firefox",
"test": "npm run install-test-deps && TS_NODE_PROJECT='./src/back/tsconfig.json' mocha --recursive --timeout 20000 --require ts-node/register --require mocha_root_hooks.js src/back/app.ts src/back/**/*.test.ts src/back/**/**/*.test.ts src/back/**/**/**/*.test.ts src/back/**/**/**/**/*.test.ts",
"coverage": "nyc npm run test --",
"postinstall": "sealgen make-env",
"test-reports": "docker-compose up -d && ./npm.sh run coverage -- --reporter xunit --reporter-option output=.xunit",
"show-coverage": "npm run test-reports; xdg-open coverage/index.html"
},
"tmux-scripts": {
"watch": [
"npm run typecheck:back -- --watch",
"npm run build -- --watch",
"npm run typecheck:front -- --watch",
"SEALIOUS_PORT=$SEALIOUS_PORT SEALIOUS_BASE_URL=$SEALIOUS_BASE_URL nodemon --enable-source-maps ."
]
},
"author": "",
"license": "ISC",
"dependencies": {
"@babel/core": "^7.12.10",
"@hotwired/turbo": "^7.1.0",
"@koa/router": "^10.1.1",
"@playwright/test": "^1.36.1",
"@sealcode/sealgen": "^0.8.39",
"@sealcode/ts-predicates": "^0.4.0",
"@sealcode/ts-predicates": "^0.4.3",
"@types/kill-port": "^2.0.0",
"hint": "^7.0.1",
"locreq": "^2.0.2",
"multiple-scripts-tmux": "^1.0.4",
"nodemon": "^2.0.7",
"sealious": "^0.17.21",
"nodemon": "^3.0.1",
"sealious": "^0.17.33",
"stimulus": "^2.0.0",
"tempstream": "^0.0.21"
},
@ -39,7 +51,8 @@
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@sealcode/ansi-html-stream": "^1.0.1",
"@types/koa__router": "^8.0.4",
"@types/mocha": "^9.1.0",
"@types/mocha": "^9.1.1",
"@types/node": "^20.8.4",
"@types/tedious": "^4.0.7",
"@typescript-eslint/eslint-plugin": "^5.10.0",
"@typescript-eslint/parser": "^5.10.2",

@ -2,6 +2,7 @@ import _locreq from "locreq";
import { default as Sealious, App, LoggerMailer, SMTPMailer } from "sealious";
import { LoggerLevel } from "sealious/@types/src/app/logger";
import { collections } from "./collections/collections";
import ADMIN_CREDENTIALS from "./default-admin-credentials";
const locreq = _locreq(__dirname);
const PORT = process.env.SEALIOUS_PORT ? parseInt(process.env.SEALIOUS_PORT) : 8080;
@ -47,7 +48,7 @@ export default class TheApp extends App {
version: "0.0.1",
default_language: "en",
base_url,
admin_email: "admin@example.com",
admin_email: ADMIN_CREDENTIALS.email,
colors: {
primary: "#5294a1",
},

@ -5,6 +5,7 @@ import _GroupsToUsers from "./groups-to-users";
import _Groups from "./groups";
import _PasswordResetIntents from "./password-reset-intents";
import _Secrets from "./secrets";
import _Tasks from "./tasks";
import _UserRoles from "./user-roles";
import _Users from "./users";
@ -12,6 +13,7 @@ export const GroupsToUsers = new _GroupsToUsers();
export const Groups = new _Groups();
export const PasswordResetIntents = new _PasswordResetIntents();
export const Secrets = new _Secrets();
export const Tasks = new _Tasks();
export const UserRoles = new _UserRoles();
export const Users = new _Users();
@ -21,6 +23,7 @@ export const collections = {
groups: Groups,
"password-reset-intents": PasswordResetIntents,
secrets: Secrets,
tasks: Tasks,
"user-roles": UserRoles,
users: Users,
};

@ -3,7 +3,8 @@ import assert from "assert";
import TheApp from "../app";
import { withProdApp } from "../test_utils/with-prod-app";
describe("password-reset-intents", () => {
describe("password-reset-intents", function () {
//ts-ignore
async function createAUser(app: TheApp) {
await app.collections.users.suCreate({
username: "user",
@ -13,8 +14,8 @@ describe("password-reset-intents", () => {
});
}
it("tells you if the email address doesn't exist", async () =>
withProdApp(async ({ app, base_url }) => {
it("tells you if the email address doesn't exist", async function () {
return withProdApp(async ({ app, base_url }) => {
const email = "fake@example.com";
try {
await axios.post(
@ -31,7 +32,8 @@ describe("password-reset-intents", () => {
return;
}
throw new Error("it didn't throw");
}));
});
});
it("allows anyone to create an intent, if the email exists", async () =>
withProdApp(async ({ app, base_url }) => {

@ -0,0 +1,21 @@
import { Collection, FieldTypes, Policies } from "sealious";
export default class Tasks extends Collection {
fields = {
title: new FieldTypes.Text(),
done: new (class extends FieldTypes.Boolean {
hasDefaultValue = () => true;
async getDefaultValue() {
return false;
}
})(),
};
policies = {
create: new Policies.Public(),
show: new Policies.Owner(),
list: new Policies.Owner(),
};
defaultPolicy = new Policies.Public();
}

@ -1,11 +1,13 @@
import { App, Collections, Context, FieldTypes, Policies } from "sealious";
import assert from "assert";
import TheApp from "../app";
import ADMIN_CREDENTIALS from "../default-admin-credentials";
export default class Users extends Collections.users {
fields = {
...App.BaseCollections.users.fields,
email: new FieldTypes.Email().setRequired(true),
username: new FieldTypes.Username().setRequired(true),
roles: new FieldTypes.ReverseSingleReference({
referencing_collection: "user-roles",
referencing_field: "user",
@ -18,7 +20,7 @@ export default class Users extends Collections.users {
assert(app instanceof TheApp);
await super.init(app, name);
app.on("started", async () => {
const username = "admin";
const username = ADMIN_CREDENTIALS.username;
const users = await app.collections.users
.suList()
.filter({ username })
@ -30,8 +32,8 @@ export default class Users extends Collections.users {
);
await app.collections.users.suCreate({
username,
password: "adminadmin",
email: "admin@example.com",
password: ADMIN_CREDENTIALS.password,
email: ADMIN_CREDENTIALS.email,
roles: [],
});
}

@ -0,0 +1,7 @@
const ADMIN_CREDENTIALS = {
username: "admin",
password: "adminadmin",
email: "admin@example.com",
};
export default ADMIN_CREDENTIALS;

@ -1,4 +1,4 @@
import { App, EmailTemplates, Errors } from "sealious";
import { EmailTemplates, Errors } from "sealious";
import TheApp from "../app";
export default async function PasswordResetTemplate(
@ -18,7 +18,7 @@ export default async function PasswordResetTemplate(
return EmailTemplates.Simple(app, {
subject: app.i18n("password_reset_email_subject", [app.manifest.name]),
to: `${username}<${email_address}>`,
to: `${String(username)}<${email_address}>`,
text: `
${app.i18n("password_reset_email_text", [app.manifest.name, username])}`,
buttons: [

@ -10,7 +10,7 @@ kill(app.config["www-server"].port)
.then(() => app.start())
.then(async () => {
if (process.env.SEALIOUS_SANITY === "true") {
console.log("Exiting with error code 0");
console.error("Exiting with error code 0");
process.exit(0);
}
mainRouter(app.HTTPServer.router);
@ -18,7 +18,7 @@ kill(app.config["www-server"].port)
.catch((error) => {
console.error(error);
if (process.env.SEALIOUS_SANITY === "true") {
console.log("EXITING WITH STATUS 1");
console.error("EXITING WITH STATUS 1");
process.exit(1);
}
});

@ -1,7 +1,7 @@
import { BaseContext } from "koa";
import { hasShape, is, predicates } from "@sealcode/ts-predicates";
import { Collection, Errors } from "sealious";
import { ItemFields } from "sealious/@types/src/chip-types/collection-item-body";
import ItemFields from "sealious/@types/src/chip-types/collection-item-body";
export interface CollectionTiedFormData<C extends Collection> {
values: Partial<{ [field in keyof ItemFields<C>]: string }>;

@ -1,6 +1,23 @@
import { BaseContext } from "koa";
import { SignUpURL, SignInURL, TodoURL, LogoutURL } from "../urls";
export default async function navbar(ctx: BaseContext) {
const isLoggedIn = !!ctx.$context.session_id;
const linkData = isLoggedIn
? [
{ text: "Logout", url: LogoutURL },
{ text: "To do app", url: TodoURL },
]
: [
{ text: "Sign in", url: SignInURL },
{ text: "Sign up", url: SignUpURL },
];
const linksHTML = linkData
.map((link) => `<li><a href="${link.url}">${link.text}</a></li>`)
.join("\n");
return /* HTML */ ` <nav>
<a href="/" class="nav-logo">
<img
@ -12,7 +29,7 @@ export default async function navbar(ctx: BaseContext) {
Sealious App
</a>
<ul>
<li><a href="/logowanie">Logowanie</a></li>
${linksHTML}
</ul>
</nav>`;
}

@ -0,0 +1,53 @@
import { BaseContext } from "koa";
import { CollectionItem } from "sealious";
import frame from "../../frame";
import { Tasks } from "../../collections/collections";
export function Task(task: CollectionItem<typeof Tasks>) {
return frame(
`task-${task.id}`,
/* HTML */ `<li class="task">
<input
type="checkbox"
data-controller="task"
data-action="task#toggle"
data-id="${task.id}"
${task.get("done") ? "checked" : ""}
/>
${task.get("title") as string}
<form method="POST" action="/todo/">
<input class="delete-button" type="submit" value="Delete" />
<input
class="hidden-button"
type="hidden"
name="taskId"
value="${task.id}"
/>
<input
class="hidden-button"
type="hidden"
id="action"
name="action"
value="delete"
/>
</form>
</li>`
);
}
/*
<form method="DELETE" action="/todo/${task.id}">
<input class="delete-button" type="submit" value="Delete" />
</form>
*/
export async function TaskList(ctx: BaseContext) {
const { items: tasks } = await ctx.$app.collections.tasks.list(ctx.$context).fetch();
const tasksTemplate = tasks.map(Task).join("\n");
return `
<ul>
${tasksTemplate}
</ul>
`;
}

@ -1,10 +1,10 @@
import { withProdApp } from "../test_utils/with-prod-app";
import { LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint";
import { VERY_LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint";
import { HelloURL } from "./urls";
describe("Hello", () => {
it("doesn't crash", async function () {
this.timeout(LONG_TEST_TIMEOUT);
this.timeout(VERY_LONG_TEST_TIMEOUT);
return withProdApp(async ({ base_url, rest_api }) => {
await rest_api.get(HelloURL);
await webhintURL(base_url + HelloURL);

@ -1,8 +1,8 @@
import { webhintURL } from "../test_utils/webhint";
import { VERY_LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint";
import { withProdApp } from "../test_utils/with-prod-app";
describe("homepage", function () {
this.timeout(200000);
this.timeout(VERY_LONG_TEST_TIMEOUT);
it("passes webhint tests", () =>
withProdApp(async ({ base_url }) => {
await webhintURL(`${base_url}/`);

@ -0,0 +1,30 @@
import { Context } from "koa";
import { Mountable } from "@sealcode/sealgen";
import Router from "@koa/router";
export const actionName = "Logout";
export default new (class LogoutRedirect extends Mountable {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async canAccess(_: Context) {
return { canAccess: true, message: "" };
}
mount(router: Router, path: string) {
router.get(path, async (ctx) => {
try {
const session_id: string = ctx.cookies.get("sealious-session") as string;
if (session_id) {
await ctx.$app.collections.sessions.logout(
new ctx.$app.SuperContext(),
session_id
);
ctx.status = 302;
ctx.redirect("/");
}
} catch (error) {
console.error("Error during logout:", error);
}
});
}
})();

@ -0,0 +1,55 @@
import assert from "assert";
import { withProdApp } from "../test_utils/with-prod-app";
import { LONG_TEST_TIMEOUT, VERY_LONG_TEST_TIMEOUT } from "../test_utils/webhint";
import { LogoutURL, SignInURL } from "./urls";
import { Browser, BrowserContext, Page } from "@playwright/test";
import { getBrowser } from "../test_utils/browser-creator";
import ADMIN_CREDENTIALS from "../default-admin-credentials";
describe("Logout", () => {
let page: Page;
let browser: Browser;
let context: BrowserContext;
const username = ADMIN_CREDENTIALS.username;
const password = ADMIN_CREDENTIALS.password;
beforeEach(async () => {
browser = await getBrowser();
context = await browser.newContext();
page = await context.newPage();
});
afterEach(async () => {
await context.close();
});
it("doesn't crash", async function () {
this.timeout(VERY_LONG_TEST_TIMEOUT);
return withProdApp(async ({ rest_api }) => {
await assert.rejects(
async () => {
await rest_api.get(LogoutURL);
},
{ name: "Error" }
);
});
});
describe("logout test", () => {
it("logout", async function () {
await withProdApp(async ({ base_url }) => {
this.timeout(LONG_TEST_TIMEOUT);
await page.goto(base_url);
await page.getByRole("link", { name: "Sign in" }).click();
await page.getByPlaceholder("text").click();
await page.getByPlaceholder("text").fill(username);
await page.getByPlaceholder("text").press("Tab");
await page.getByPlaceholder("password").fill(password);
await page.getByPlaceholder("password").press("Enter");
await page.waitForSelector(`a[href="${LogoutURL}"]`);
await page.getByRole("link", { name: "Logout" }).click();
await page.waitForSelector(`a[href="${SignInURL}"]`);
});
});
});
});

@ -5,7 +5,15 @@ import { mount } from "@sealcode/sealgen";
import * as URLs from "./urls";
import { default as Hello } from "./hello.page";
import { default as Logout } from "./logout.redirect";
import { default as SignIn } from "./signIn.form";
import { default as SignUp } from "./signUp.form";
import { default as Todo } from "./todo.form";
export default function mountAutoRoutes(router: Router) {
mount(router, URLs.HelloURL, Hello);
mount(router, URLs.LogoutURL, Logout);
mount(router, URLs.SignInURL, SignIn);
mount(router, URLs.SignUpURL, SignUp);
mount(router, URLs.TodoURL, Todo);
}

@ -0,0 +1,121 @@
import { Context } from "koa";
import {
Form,
FormData,
FormDataValue,
Fields,
Controls,
FormReaction,
} from "@sealcode/sealgen";
import html from "../html";
import { Users } from "../collections/collections";
import { FlatTemplatable, tempstream } from "tempstream";
import { PageErrorMessage } from "@sealcode/sealgen/@types/page/mountable-with-fields";
export const actionName = "SignIn";
const fields = {
username: new Fields.SimpleFormField(true),
password: new Fields.SimpleFormField(true),
};
export const SignInShape = Fields.fieldsToShape(fields);
export default new (class SignInForm extends Form<typeof fields, void> {
defaultSuccessMessage = "Formularz wypełniony poprawnie";
fields = fields;
controls = [
new Controls.SimpleInput(fields.username, { label: "Username:", type: "text" }),
new Controls.SimpleInput(fields.password, {
label: "Password:",
type: "password",
}),
];
async validateValues(
ctx: Context,
data: Record<string, FormDataValue>
): Promise<{ valid: boolean; error: string }> {
const { parsed: username } = await this.fields.username.getValue(ctx, data);
const filter: object = typeof username === "string" ? { username } : {};
const user = await Users.suList().filter(filter).fetch();
if (user.empty) {
return { valid: false, error: `Incorrect password or username` };
}
return { valid: true, error: `` };
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async canAccess(ctx: Context) {
if (ctx.$context.session_id) {
return { canAccess: false, message: "" };
}
return { canAccess: true, message: "" };
}
async onSuccess(
_: Context,
__: FormData<string>,
_submitResult: void
): Promise<FormReaction> {
const reaction: FormReaction = {
action: "redirect",
url: "/",
};
console.log("Successfully logged in.");
return reaction;
}
async onError(
ctx: Context,
data: FormData<string>,
error: unknown
): Promise<FormReaction> {
const reaction: FormReaction = {
action: "stay",
content: this.render(ctx, data, true),
messages: [
{
type: "error",
text: `There was an error while logging in: ${String(error)}`,
},
],
};
return reaction;
}
async onSubmit(ctx: Context, data: FormData) {
try {
const sessionId: string = await Users.app.collections.sessions.login(
data.raw_values.username as string,
data.raw_values.password as string
);
ctx.cookies.set("sealious-session", sessionId, {
maxAge: 1000 * 60 * 60 * 24 * 7,
secure: ctx.request.protocol === "https",
overwrite: true,
});
} catch (error) {
throw new Error(String(error));
}
return;
}
async renderError(ctx: Context, error: PageErrorMessage): Promise<FlatTemplatable> {
return html(ctx, "SignIn", `${error.message}`);
}
async render(ctx: Context, data: FormData, show_field_errors: boolean) {
return html(
ctx,
"SignIn",
tempstream`${await super.render(ctx, data, show_field_errors)}`
);
}
})();

@ -0,0 +1,103 @@
import { withProdApp } from "../test_utils/with-prod-app";
import { VERY_LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint";
import { SignInURL, LogoutURL } from "./urls";
import { Browser, BrowserContext, Page } from "@playwright/test";
import { getBrowser } from "../test_utils/browser-creator";
import ADMIN_CREDENTIALS from "../default-admin-credentials";
describe("SignIn", () => {
let page: Page;
let browser: Browser;
let context: BrowserContext;
const username = ADMIN_CREDENTIALS.username;
const password = ADMIN_CREDENTIALS.password;
beforeEach(async () => {
browser = await getBrowser();
context = await browser.newContext();
page = await context.newPage();
});
afterEach(async () => {
await context.close();
});
it("doesn't crash", async function () {
this.timeout(VERY_LONG_TEST_TIMEOUT);
return withProdApp(async ({ base_url, rest_api }) => {
await rest_api.get(SignInURL);
await webhintURL(base_url + SignInURL);
// alternatively you can use webhintHTML for faster but less precise scans
// or for scanning responses of requests that use some form of authorization:
// const response = await rest_api.get(SignInURL);
// await webhintHTML(response);
});
});
describe("can access test", () => {
it("access url", async function () {
await withProdApp(async ({ base_url }) => {
this.timeout(VERY_LONG_TEST_TIMEOUT);
await page.goto(base_url);
await page.getByRole("link", { name: "Sign in" }).click();
await page.getByPlaceholder("text").click();
await page.getByPlaceholder("text").fill(username);
await page.getByPlaceholder("text").press("Tab");
await page.getByPlaceholder("password").fill(password);
await page.getByPlaceholder("password").press("Enter");
await page.waitForSelector(`a[href="${LogoutURL}"]`);
await page.goto(base_url + SignInURL);
await page.waitForSelector('body:has-text("no access")');
await page.goto(base_url);
await page.getByRole("link", { name: "Logout" }).click();
await page.waitForSelector(`a[href="${SignInURL}"]`);
});
});
});
describe("sign in test", () => {
it("wrong username", async function () {
await withProdApp(async ({ base_url }) => {
this.timeout(VERY_LONG_TEST_TIMEOUT);
await page.goto(base_url);
await page.getByRole("link", { name: "Sign in" }).click();
await page.getByPlaceholder("text").click();
await page.getByPlaceholder("text").fill("username20230720722");
await page.getByPlaceholder("text").press("Tab");
await page.getByPlaceholder("password").fill("test");
await page.getByPlaceholder("password").press("Enter");
await page.waitForSelector(".form-message");
});
});
it("correct username and password", async function () {
await withProdApp(async ({ base_url }) => {
this.timeout(VERY_LONG_TEST_TIMEOUT);
await page.goto(base_url);
await page.getByRole("link", { name: "Sign in" }).click();
await page.getByPlaceholder("text").click();
await page.getByPlaceholder("text").fill(username);
await page.getByPlaceholder("text").press("Tab");
await page.getByPlaceholder("password").fill(password);
await page.getByPlaceholder("password").press("Enter");
await page.waitForSelector(`a[href="${LogoutURL}"]`);
await page.getByRole("link", { name: "Logout" }).click();
await page.waitForSelector(`a[href="${SignInURL}"]`);
});
});
it("wrong password", async function () {
await withProdApp(async ({ base_url }) => {
this.timeout(VERY_LONG_TEST_TIMEOUT);
await page.goto(base_url);
await page.getByRole("link", { name: "Sign in" }).click();
await page.getByPlaceholder("text").click();
await page.getByPlaceholder("text").fill(username);
await page.getByPlaceholder("text").press("Tab");
await page.getByPlaceholder("password").fill("asddasads20230720722");
await page.getByPlaceholder("password").press("Enter");
await page.waitForSelector(".form-message");
});
});
});
});

@ -0,0 +1,139 @@
import { Context } from "koa";
import {
Form,
FormData,
FormDataValue,
Fields,
Controls,
FormReaction,
} from "@sealcode/sealgen";
import html from "../html";
import { Users } from "../collections/collections";
export const actionName = "SignUp";
const fields = {
username: new Fields.CollectionField(true, Users.fields.username),
email: new Fields.CollectionField(true, Users.fields.email),
password: new Fields.SimpleFormField(true),
};
export const SignUpShape = Fields.fieldsToShape(fields);
export default new (class SignUpForm extends Form<typeof fields, void> {
defaultSuccessMessage = "Formularz wypełniony poprawnie";
fields = fields;
controls = [
new Controls.SimpleInput(fields.username, { label: "Username:", type: "text" }),
new Controls.SimpleInput(fields.email, { label: "Email:", type: "email" }),
new Controls.SimpleInput(fields.password, {
label: "Password:",
type: "password",
}),
];
async validateValues(
ctx: Context,
data: Record<string, FormDataValue>
): Promise<{ valid: boolean; error: string }> {
const { parsed: email } = await this.fields.email.getValue(ctx, data);
const { parsed: password } = await this.fields.password.getValue(ctx, data);
if ((password || "").length >= 8) {
const user = await Users.suList().filter({ email: email }).fetch();
if (user.empty) {
return { valid: true, error: `` };
}
return { valid: false, error: `Email is arleady taken` };
} else {
return {
valid: false,
error: "Password must contain a minimum of 8 characters",
};
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async canAccess(ctx: Context) {
if (ctx.$context.session_id) {
return { canAccess: false, message: "" };
}
return { canAccess: true, message: "" };
}
async onError(
ctx: Context,
data: FormData<string>,
error: unknown
): Promise<FormReaction> {
const reaction: FormReaction = {
action: "stay",
content: this.render(ctx, data, true),
messages: [
{
type: "error",
text: `An unexpected error occurred, try again. <br> Error${
error as string
}`,
},
],
};
return reaction;
}
async onSuccess(ctx: Context, data: FormData): Promise<FormReaction> {
const username: FormDataValue = data.raw_values.username;
const reaction: FormReaction = {
action: "stay",
content: `Hello ${String(
username
)}. <p class="success-notify">Your account has been successfully created.</p>
<a href="/" class="nav-logo">
<img
src="/assets/logo"
alt="${ctx.$app.manifest.name} - logo"
width="50"
height="50"
/>
Sealious App
</a>`,
messages: [
{
type: "success",
text: "",
},
],
};
return reaction;
}
async onSubmit(ctx: Context, data: FormData) {
const username: string =
typeof data.raw_values.username === "string" ? data.raw_values.username : "";
const password: string =
typeof data.raw_values.password === "string" ? data.raw_values.password : "";
const email: string =
typeof data.raw_values.email === "string" ? data.raw_values.email : "";
try {
await Users.suCreate({
username: username,
password: password,
email: email,
roles: [],
});
console.log("A user was created successfully.");
} catch (error) {
console.error("Error during user creation:", error);
throw new Error(String(error));
}
return;
}
async render(ctx: Context, data: FormData, show_field_errors: boolean) {
return html(ctx, "SignUp", await super.render(ctx, data, show_field_errors));
}
})();

@ -0,0 +1,122 @@
import { withProdApp } from "../test_utils/with-prod-app";
import { VERY_LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint";
import { SignUpURL, LogoutURL, SignInURL } from "./urls";
import { Browser, BrowserContext, Page } from "@playwright/test";
import { getBrowser } from "../test_utils/browser-creator";
import ADMIN_CREDENTIALS from "../default-admin-credentials";
describe("SignUp", () => {
let page: Page;
let browser: Browser;
let context: BrowserContext;
const username = ADMIN_CREDENTIALS.username;
const password = ADMIN_CREDENTIALS.password;
const email = ADMIN_CREDENTIALS.email;
beforeEach(async () => {
browser = await getBrowser();
context = await browser.newContext();
page = await context.newPage();
});
afterEach(async () => {
await context.close();
});
it("doesn't crash", async function () {
this.timeout(VERY_LONG_TEST_TIMEOUT);
return withProdApp(async ({ base_url, rest_api }) => {
await rest_api.get(SignUpURL);
await webhintURL(base_url + SignUpURL);
// alternatively you can use webhintHTML for faster but less precise scans
// or for scanning responses of requests that use some form of authorization:
// const response = await rest_api.get(SignUpURL);
// await webhintHTML(response);
});
});
describe("signup test", () => {
it("username is taken", async function () {
await withProdApp(async ({ base_url }) => {
this.timeout(VERY_LONG_TEST_TIMEOUT);
await page.goto(base_url);
await page.getByRole("link", { name: "Sign up" }).click();
await page.getByPlaceholder("text").click();
await page.getByPlaceholder("text").fill(username);
await page.getByPlaceholder("text").press("Tab");
await page.getByPlaceholder("email").fill("user0192939@randomsuper.com");
await page.getByPlaceholder("email").press("Tab");
await page.getByPlaceholder("password").fill("user12341234");
await page.getByRole("button", { name: "Wyślij" }).click();
await page.waitForSelector(".input__error");
});
});
it("password is too shot ", async function () {
await withProdApp(async ({ base_url }) => {
this.timeout(VERY_LONG_TEST_TIMEOUT);
await page.goto(base_url);
await page.getByRole("link", { name: "Sign up" }).click();
await page.getByPlaceholder("text").click();
await page.getByPlaceholder("text").fill("dasdsa");
await page.getByPlaceholder("email").click();
await page
.getByPlaceholder("email")
.fill("asasdsdadsadss123asddsa@asdasca.com");
await page.getByPlaceholder("password").click();
await page.getByPlaceholder("password").fill("asddsa");
await page.getByRole("button", { name: "Wyślij" }).click();
await page.waitForSelector(".form-message.form-message--error");
});
});
it("email is taken", async function () {
await withProdApp(async ({ base_url }) => {
this.timeout(VERY_LONG_TEST_TIMEOUT);
await page.goto(base_url);
await page.getByRole("link", { name: "Sign up" }).click();
await page.getByPlaceholder("text").click();
await page.getByPlaceholder("text").fill("ranomusername2023072722");
await page.getByPlaceholder("text").press("Tab");
await page.getByPlaceholder("email").fill(email);
await page.getByPlaceholder("email").press("Tab");
await page.getByPlaceholder("password").fill("asdasdasdasdasd");
await page.getByRole("button", { name: "Wyślij" }).click();
await page.waitForSelector(".form-message.form-message--error");
});
});
it("correct", async function () {
await withProdApp(async ({ base_url }) => {
this.timeout(VERY_LONG_TEST_TIMEOUT);
await page.goto(base_url);
await page.getByRole("link", { name: "Sign up" }).click();
await page.getByPlaceholder("text").click();
await page.getByPlaceholder("text").fill("ranomusername20230720722");
await page.getByPlaceholder("text").press("Tab");
await page.getByPlaceholder("email").fill("radomemail@emailrandom.com");
await page.getByPlaceholder("email").press("Tab");
await page.getByPlaceholder("password").fill("asdasdasdasdasd");
await page.getByRole("button", { name: "Wyślij" }).click();
await page.waitForSelector(".success-notify");
});
});
});
describe("can access test", () => {
it("access url", async function () {
await withProdApp(async ({ base_url }) => {
this.timeout(VERY_LONG_TEST_TIMEOUT);
await page.goto(base_url);
await page.getByRole("link", { name: "Sign in" }).click();
await page.getByPlaceholder("text").click();
await page.getByPlaceholder("text").fill(username);
await page.getByPlaceholder("text").press("Tab");
await page.getByPlaceholder("password").fill(password);
await page.getByPlaceholder("password").press("Enter");
await page.waitForSelector(`a[href="${LogoutURL}"]`);
await page.goto(base_url + SignUpURL);
await page.waitForSelector('body:has-text("no access")');
await page.goto(base_url);
await page.getByRole("link", { name: "Logout" }).click();
await page.waitForSelector(`a[href="${SignInURL}"]`);
});
});
});
});

@ -0,0 +1,107 @@
import { Tasks } from "./../collections/collections";
import { tempstream } from "tempstream";
import { Context } from "koa";
import { Form, FormData, FormDataValue, Fields, Controls } from "@sealcode/sealgen";
import html from "../html";
import { TaskList } from "./common/tasks-view";
export const actionName = "Todo";
const fields = {
name: new Fields.CollectionField(true, Tasks.fields.title),
};
export const TodoShape = Fields.fieldsToShape(fields);
export default new (class TodoForm extends Form<typeof fields, void> {
defaultSuccessMessage = "Task has been successfully created";
fields = fields;
controls = [
new Controls.SimpleInput(fields.name, {
label: "Task name:",
type: "text",
placeholder: "Write an Matrix bot",
}),
new Controls.HTML("decoration", (fctx) => {
return `<input class="hidden-button" type="hidden" id="action" name="action" value="create" form="${fctx.form_id}" />`;
}),
];
async validateValues(
ctx: Context,
data: Record<string, FormDataValue>
): Promise<{ valid: boolean; error: string }> {
const { parsed: name } = await this.fields.name.getValue(ctx, data);
if ((name || "").length < 3) {
return {
valid: true,
error: "The name of the task must have at least 3 characters",
};
} else {
const filter: object = name ? { title: name } : {};
const tasks = await ctx.$app.collections.tasks
.list(ctx.$context)
.filter(filter)
.fetch();
if (tasks.empty) {
return { valid: true, error: "" };
}
return { valid: false, error: "Task with the same name already exists" };
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async canAccess(ctx: Context) {
if (ctx.$context.session_id) {
return { canAccess: true, message: "" };
}
return { canAccess: false, message: "" };
}
async onSubmit(ctx: Context, data: FormData) {
const action: FormDataValue = data.raw_values.action;
switch (action) {
case "create": {
try {
await ctx.$app.collections.tasks.create(ctx.$context, {
title: String(data.raw_values.name),
done: false,
});
} catch (error) {
throw new Error();
}
console.debug(`task has been successfully created`);
break;
}
case "delete": {
const task = await ctx.$app.collections.tasks.getByID(
ctx.$context,
data.raw_values.taskId as string
);
await task.remove(ctx.$context);
console.debug(`task has been successfully removed`);
break;
}
default: {
console.debug("Wrong action");
break;
}
}
return;
}
async render(ctx: Context, data: FormData, show_field_errors: boolean) {
return html(
ctx,
"Todo",
tempstream`${await super.render(ctx, data, show_field_errors)}
${TaskList(ctx)}
`
);
}
})();

@ -0,0 +1,81 @@
import assert from "assert";
import { withProdApp } from "../test_utils/with-prod-app";
import { LONG_TEST_TIMEOUT, VERY_LONG_TEST_TIMEOUT } from "../test_utils/webhint";
import { SignInURL, TodoURL } from "./urls";
import { Browser, BrowserContext, Page } from "@playwright/test";
import { getBrowser } from "../test_utils/browser-creator";
import ADMIN_CREDENTIALS from "../default-admin-credentials";
describe("Todo", function () {
let page: Page;
let browser: Browser;
let context: BrowserContext;
const username = ADMIN_CREDENTIALS.username;
const password = ADMIN_CREDENTIALS.password;
beforeEach(async () => {
browser = await getBrowser();
context = await browser.newContext();
page = await context.newPage();
});
afterEach(async () => {
await context.close();
});
it("doesn't crash", async function () {
this.timeout(VERY_LONG_TEST_TIMEOUT);
return withProdApp(async ({ rest_api }) => {
await assert.rejects(
async () => {
await rest_api.get(TodoURL);
},
{ name: "Error" }
);
});
});
describe("todo test", () => {
it("create and delete task", async function () {
await withProdApp(async ({ base_url }) => {
this.timeout(VERY_LONG_TEST_TIMEOUT);
await page.goto(base_url);
await page.getByRole("link", { name: "Sign in" }).click();
await page.getByPlaceholder("text").click();
await page.getByPlaceholder("text").fill(username);
await page.getByPlaceholder("text").press("Tab");
await page.getByPlaceholder("password").fill(password);
await page.getByPlaceholder("password").press("Enter");
await page.getByRole("link", { name: "To do app" }).click();
await page.getByPlaceholder("Write an Matrix bot").click();
await page.getByPlaceholder("Write an Matrix bot").fill("randomtasdk");
await page.getByRole("button", { name: "Wyślij" }).click();
await page.waitForSelector(".form-message.form-message--success");
await page.locator("turbo-frame").getByRole("checkbox").check();
await page.locator("turbo-frame").getByRole("checkbox").uncheck();
await page
.locator("turbo-frame")
.getByRole("button", { name: "Delete" })
.click();
await page.getByRole("link", { name: "Logout" }).click();
await page.waitForSelector(`a[href="${SignInURL}"]`);
});
});
});
describe("can access test", () => {
it("access url", async function () {
await withProdApp(async ({ base_url }) => {
this.timeout(LONG_TEST_TIMEOUT);
await page.goto(base_url);
try {
await page.waitForSelector(`a[href="${SignInURL}"]`);
await page.goto(base_url + TodoURL);
await page.waitForSelector('body:has-text("no access")');
} catch (error) {
console.error(error);
}
});
});
});
});

@ -1 +1,5 @@
export const HelloURL = "/hello/";
export const LogoutURL = "/logout/";
export const SignInURL = "/signIn/";
export const SignUpURL = "/signUp/";
export const TodoURL = "/todo/";

@ -1,5 +1,5 @@
describe("sample test", () => {
it("always passes", () => {
return true;
return;
});
});

@ -0,0 +1,16 @@
import { Browser, firefox } from "@playwright/test";
let browser: Browser;
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export async function getBrowser(): Promise<Browser> {
if (!browser) browser = await firefox.launch();
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return browser;
}
export async function closeBrowser() {
if (browser) {
await browser.close();
}
}

@ -4,7 +4,8 @@ import { spawn } from "child_process";
import { hasShape, is, predicates } from "@sealcode/ts-predicates";
import { promises as fs } from "fs";
export const LONG_TEST_TIMEOUT = 30 * 1000;
export const LONG_TEST_TIMEOUT = 100 * 1000;
export const VERY_LONG_TEST_TIMEOUT = 75 * 1000;
export async function webhintURL(url: string, config = locreq.resolve(".hintrc")) {
// eslint-disable-next-line no-console

@ -1,9 +1,10 @@
import TheApp from "../app";
import { mainRouter } from "../routes";
import _locreq from "locreq";
import { v4 as uuid } from "uuid";
const locreq = _locreq(__dirname);
import Sealious, { SMTPMailer } from "sealious";
import { SMTPMailer } from "sealious";
import { TestUtils } from "sealious";
export async function withProdApp(
@ -21,7 +22,7 @@ export async function withProdApp(
app.config.datastore_mongo = {
host: "db",
port: 27017,
db_name: "sealious-app-test",
db_name: "sealious-app-test" + uuid(),
};
app.config.logger.level = <const>"none";
app.mailer = new SMTPMailer({
@ -31,11 +32,11 @@ export async function withProdApp(
password: "any",
});
await app.start();
mainRouter(app.HTTPServer.router);
app.HTTPServer.addStaticRoute("/", locreq.resolve("public"));
await app.start();
const base_url = `http://127.0.0.1:${port}`;
const mail_api = new TestUtils.MailcatcherAPI("http://mailcatcher:1080", app);
await mail_api.deleteAllInstanceEmails();
@ -52,9 +53,12 @@ export async function withProdApp(
rest_api: new TestUtils.MockRestApi(base_url),
mail_api,
});
await stop();
} catch (e) {
await stop();
if (app.status !== "stopped") {
await stop();
}
console.error(e);
throw e;
}

@ -10,7 +10,7 @@
"lib": ["es6", "esnext"],
"outDir": "../../dist/back",
"keyofStringsOnly": true,
"checkJs": true,
"checkJs": false,
"allowJs": true,
"resolveJsonModule": true,
"sourceMap": true,

@ -0,0 +1,30 @@
import { Controller } from "stimulus";
export default class TaskController extends Controller {
id: string;
connect() {
const dataIdAttr = this.element.getAttribute("data-id");
if (dataIdAttr) {
this.id = dataIdAttr;
}
}
async toggle(event: Event) {
const inputElement: HTMLInputElement = event.target as HTMLInputElement;
if (inputElement instanceof HTMLInputElement) {
const isChecked: boolean = inputElement.checked;
await fetch(`/api/v1/collections/tasks/${this.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
done: isChecked,
}),
});
}
}
}

@ -1,6 +1,8 @@
import * as Turbo from "@hotwired/turbo";
import { Application } from "stimulus";
import TaskController from "./controllers/task-controller";
export { Turbo };
const application = Application.start();
application.register("task", TaskController);

@ -2,7 +2,8 @@
"compilerOptions": {
"module": "CommonJS",
"target": "ES6",
"lib": ["dom"]
"lib": ["dom"],
"skipLibCheck": true
},
"include": ["./**/*", "./index.ts"]
}

Loading…
Cancel
Save