Playwright tests & CI (#69)

* get tests started

* Create opensaas-ci.yml

* move github workflows

* modify ci scripts

* install linebyline

* Update package.json

* install wait-port

* Update package.json

* add conditional webserver when in CI env

* test tagging action

* Update retag-commit.yml

* remove unused workflow

* add .env file and test stripe webhook

* Update e2e-tests.yml

* Update e2e-tests.yml

* Update e2e-tests.yml

* Update e2e-tests.yml

* test demo app

* Update e2e-tests.yml

* update github action versions

* disable wasp telemetry

* Cleanup running (#72)

* improve tests

* Update ci-start-app.js

* remove npm scripts

* Update e2e-tests.yml

* Update ci-start-app-with-scripts.js

* Update ci-start-app-with-scripts.js

* rename test folder

* Update e2e-tests.yml

* Update e2e-tests.yml

* export prisma client from server

* Update e2e-tests.yml

* Update e2e-tests.yml

* Update e2e-tests.yml

* Update e2e-tests.yml

* Update package-lock.json

* install linebyline

* add npm scripts

* Update package.json

* Update paidUserTests.spec.ts

* update flaky test

* update tsconfig

* Update e2e-tests.yml

* Update e2e-tests.yml

* pin node version to github action

* Update e2e-tests.yml

* fix retag and clean up

* add notes on tag action

* Update retag-commit.yml

* pr changes part 1

* pr changes part 2

* Setup tests with local Prisma (#86)

Signed-off-by: Mihovil Ilakovac <mihovil@ilakovac.com>

* pr changes part 3

* add db naming script

* Update package.json

* Update package.json

* Update package.json

* spawn prisma process

* Update package.json

* move db setup

* Update package.json

* Update package.json

* Update package.json

* Update setupDatabaseName.sh

* Update package.json

* use same process for local and CI testing

* Update package.json

* wait for app for prisma setup

* Update package.json

* changes made with martin

* stripe clie

* Update e2e-tests.yml

* Update e2e-tests.yml

* update start stripe cli

* try again

* start testing stripe payments

* stripe payment works

* fix base url issue & cleanup

* Update playwright.config.ts

* Update package.json

* Update package-lock.json

* Update main.wasp

* Update utils.ts

* Update e2e-tests.yml

* Update utils.ts

* Update utils.ts

* Update e2e-tests.yml

* Update actions.ts

* Update actions.ts

* Update actions.ts

* Update actions.ts

* Update stripeUtils.ts

* Update e2e-tests.yml

* catch stripe checkout error

* remove console.logs

* fix comments, add err catches

* fix cache and cache keys

* Update .github/workflows/e2e-tests.yml

Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com>

* Update .github/workflows/e2e-tests.yml

Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com>

* Update .github/workflows/e2e-tests.yml

Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com>

* Update .github/workflows/e2e-tests.yml

Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com>

* Update e2e-tests/playwright.config.ts

Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com>

* Improve CI caching and other stuff

* fix cache key, npm scripts, error handling

* Update e2e-tests.yml

* Update README.md

* add template versioning info

---------

Signed-off-by: Mihovil Ilakovac <mihovil@ilakovac.com>
Co-authored-by: Fran Zekan <zekan.fran369@gmail.com>
Co-authored-by: Mihovil Ilakovac <mihovil@ilakovac.com>
Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com>
This commit is contained in:
vincanger 2024-04-22 10:43:25 +02:00 committed by GitHub
parent 8fa8f24486
commit 2d94e28dd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 3527 additions and 3320 deletions

107
.github/workflows/e2e-tests.yml vendored Normal file
View File

@ -0,0 +1,107 @@
name: e2e tests
on:
push:
branches:
- main
pull_request:
branches:
- main
env:
WASP_TELEMETRY_DISABLE: 1
WASP_VERSION: 0.13.2
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout the repo
uses: actions/checkout@v4
- name: Setup Node.js
id: setup-node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Wasp
run: curl -sSL https://get.wasp-lang.dev/installer.sh | sh -s -- -v ${{ env.WASP_VERSION }}
- name: Docker setup
uses: docker/setup-buildx-action@v3
# In order for the app to run in the dev mode we need to set the required env vars even if they aren't actually used by the app.
# This step sets mock env vars in order to pass the validation steps so the app can run
# in the CI environment. For env vars that are actually used in tests and therefore need real values, we set them in
# the GitHub secrets settings and access them in a step below.
- name: Set required wasp app env vars to mock values
run: |
cd app
cp .env.server.example .env.server
- name: Cache global node modules
uses: actions/cache@v4
with:
path: ~/.npm
key: node-modules-${{ runner.os }}-${{ hashFiles('app/package-lock.json') }}-${{ hashFiles('e2e-tests/package-lock.json') }}-wasp${{ env.WASP_VERSION }}-node${{ steps.setup-node.outputs.node-version }}
restore-keys: |
node-modules-${{ runner.os }}-
- name: Install Node.js dependencies for Playwright tests
if: steps.cache-e2e-tests.outputs.cache-hit != 'true'
run: |
cd e2e-tests
npm ci
- name: Store Playwright's Version
run: |
cd e2e-tests
PLAYWRIGHT_VERSION=$(npm ls @playwright/test | grep @playwright | sed 's/.*@//')
echo "Playwright's Version: $PLAYWRIGHT_VERSION"
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
- name: Cache Playwright Browsers for Playwright's Version
id: cache-playwright-browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}-${{ runner.os }}
- name: Set up Playwright
if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
run: |
cd e2e-tests
npx playwright install --with-deps
- name: Install Stripe CLI
run: |
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
sudo apt update
sudo apt install stripe
# For Stripe webhooks to work in development, we need to run the Stripe CLI to listen for webhook events.
# The Stripe CLI will receive the webhook events from Stripe test payments and
# forward them to our local server so that we can test the payment flow in our e2e tests.
- name: Run Stripe CLI to listen for webhooks
env:
STRIPE_DEVICE_NAME: ${{ secrets.STRIPE_DEVICE_NAME }}
run: |
stripe listen --api-key ${{ secrets.STRIPE_KEY }} --forward-to localhost:3001/stripe-webhook &
- name: Run Playwright tests
env:
# The e2e tests are testing parts of the app that need certain env vars, so we need to access them here.
# These secretes can be set in your GitHub repo settings, e.g. https://github.com/<account>/<repo>/settings/secrets/actions
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
HOBBY_SUBSCRIPTION_PRICE_ID: ${{ secrets.HOBBY_SUBSCRIPTION_PRICE_ID }}
PRO_SUBSCRIPTION_PRICE_ID: ${{ secrets.PRO_SUBSCRIPTION_PRICE_ID }}
CREDITS_PRICE_ID: ${{ secrets.CREDITS_PRICE_ID }}
SKIP_EMAIL_VERIFICATION_IN_DEV: true
run: |
cd e2e-tests
npm run e2e:playwright

30
.github/workflows/retag-commit.yml vendored Normal file
View File

@ -0,0 +1,30 @@
name: Retag Commit
on:
push:
branches:
- main
jobs:
retag:
runs-on: ubuntu-latest
env:
TAG_NAME: wasp-v0.13-template
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Configure Git
run: |
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
- name: Delete Old Tag
run: |
git tag -d ${{ env.TAG_NAME }} || true
git push origin :refs/tags/${{ env.TAG_NAME }} || true
- name: Add New Tag
run: |
git tag ${{ env.TAG_NAME }}
git push origin ${{ env.TAG_NAME }}

2
.gitignore vendored
View File

@ -1,7 +1,7 @@
*/.wasp/
*/.env.server
*/.env.client
*/.DS_Store
*/node_modules
*/migrations
*/.DS_Store
.DS_Store

View File

@ -38,4 +38,4 @@ Contributing is simple:
3. Create a new feature branch for your work. See [above](#the-default-template-vs-the-deployed-site--docs) for which branch to base your feature branch off of.
4. Create a pull request.
5. Make a "Da Boi" meme while you wait for us to review your PR.
6. If you don't know who "Da Boi" is, head back to the [Wasp Discord](https://discord.gg/aCamt5wCpS) and find out :)
6. If you don't know who "Da Boi" is, head back to the [Wasp Discord](https://discord.gg/aCamt5wCpS) and find out :)

View File

@ -28,6 +28,7 @@ The template itself is built on top of some very powerful tools and frameworks,
- 📧 [SendGrid](https://sendgrid.com), [MailGun](https://mailgun.com), or SMTP - for email sending
- 💅 [TailwindCSS](https://tailwindcss.com) - for styling
- 🧑‍💼 [TailAdmin](https://tailadmin.com/) - admin dashboard & components for TailwindCSS
- 🧪 [Playwright](https://playwright.dev) - end-to-end tests with Playwright
Because we're using Wasp as the full-stack framework, we can leverage a lot of its features to build our SaaS in record time, including:
@ -62,7 +63,17 @@ For everything you need to know about getting started and using this template, c
We've documented everything in great detail, including installation instructions, pulling updates to the template, guides for integrating services, SEO, deployment, and more. 🚀
## Changes & Contributions
### Template Versioning
Whenever a user starts a new Open SaaS project with `wasp new -t saas`, Wasp looks for a specific tag on the repo, and pulls the project at the commit associated with that tag. In the case of Open SaaS, the tag is `wasp-v{{version}}-template`, where `{{version}}` is the current version of Wasp, e.g. `wasp-v0.13-template`.
For simplicity, we automatically re-apply the tag to the most recent commit on the `main` branch via the `.github/workflows/retag-commit.yml` workflow. This way, users always get the latest version of the template when they start a new project via `wasp new -t saas`.d
### Contributing
Note that we've tried to get as many of the core features of a SaaS app into this template as possible, but there still might be some missing features or functionality.
We could always use some help tying up loose ends, so consider [contributing](https://github.com/wasp-lang/open-saas/blob/main/CONTRIBUTING.md)!

View File

@ -21,8 +21,8 @@ GOOGLE_CLIENT_SECRET=GOC...
# get your sendgrid api key at https://app.sendgrid.com/settings/api_keys
SENDGRID_API_KEY=test...
# if not explicitly set to true, emails will be logged to console but not actually sent during development
SEND_EMAILS_IN_DEVELOPMENT=false
# Skips the email verification flow in development every time you sign up a new user.
SKIP_EMAIL_VERIFICATION_IN_DEV=true
# (OPTIONAL) get your openai api key at https://platform.openai.com/account
OPENAI_API_KEY=sk-k...

View File

@ -1,6 +1,6 @@
app OpenSaaS {
wasp: {
version: "^0.13.0"
version: "^0.13.2"
},
title: "My Open SaaS App",
head: [

6053
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -48,22 +48,28 @@ export const stripePayment: StripePayment<string, StripePaymentResult> = async (
throw new HttpError(404, 'Invalid tier');
}
let customer: Stripe.Customer;
let session: Stripe.Checkout.Session;
let customer: Stripe.Customer | undefined;
let session: Stripe.Checkout.Session | undefined;
try {
customer = await fetchStripeCustomer(userEmail);
if (!customer) {
throw new HttpError(500, 'Error fetching customer');
}
session = await createStripeCheckoutSession({
priceId,
customerId: customer.id,
mode: tier === TierIds.CREDITS ? 'payment' : 'subscription',
});
if (!session) {
throw new HttpError(500, 'Error creating session');
}
} catch (error: any) {
const statusCode = error.statusCode || 500;
const errorMessage = error.message || 'Internal server error';
throw new HttpError(statusCode, errorMessage);
}
await context.entities.User.update({
const updatedUser = await context.entities.User.update({
where: {
id: context.user.id,
},

View File

@ -1,4 +1,5 @@
import Stripe from 'stripe';
import { HttpError } from 'wasp/server';
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15',
@ -9,19 +10,24 @@ const DOMAIN = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000';
export async function fetchStripeCustomer(customerEmail: string) {
let customer: Stripe.Customer;
const stripeCustomers = await stripe.customers.list({
email: customerEmail,
});
if (!stripeCustomers.data.length) {
console.log('creating customer');
customer = await stripe.customers.create({
try {
const stripeCustomers = await stripe.customers.list({
email: customerEmail,
});
} else {
console.log('using existing customer');
customer = stripeCustomers.data[0];
if (!stripeCustomers.data.length) {
console.log('creating customer');
customer = await stripe.customers.create({
email: customerEmail,
});
} else {
console.log('using existing customer');
customer = stripeCustomers.data[0];
}
return customer;
} catch (error: any) {
console.error(error.message);
throw error;
}
return customer;
}
export async function createStripeCheckoutSession({
@ -33,20 +39,25 @@ export async function createStripeCheckoutSession({
customerId: string;
mode: 'subscription' | 'payment';
}) {
return await stripe.checkout.sessions.create({
line_items: [
{
price: priceId,
quantity: 1,
try {
return await stripe.checkout.sessions.create({
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: mode,
success_url: `${DOMAIN}/checkout?success=true`,
cancel_url: `${DOMAIN}/checkout?canceled=true`,
automatic_tax: { enabled: true },
customer_update: {
address: 'auto',
},
],
mode: mode,
success_url: `${DOMAIN}/checkout?success=true`,
cancel_url: `${DOMAIN}/checkout?canceled=true`,
automatic_tax: { enabled: true },
customer_update: {
address: 'auto',
},
customer: customerId,
});
customer: customerId,
});
} catch (error: any) {
console.error(error.message);
throw error;
}
}

5
e2e-tests/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/reports/
/test-results/
/.cache/
/node_modules/
prisma/

41
e2e-tests/README.md Normal file
View File

@ -0,0 +1,41 @@
# Open SaaS e2e Tests with Playwright
These are e2e tests that are written using [Playwright](https://playwright.dev/) for the Open SaaS project.
They not only serve as tests for development of the Open SaaS project, but also as reference examples for how you can implement tests for the app you build with Open SaaS as a template, if you choose to do so.
## Running the tests
### Locally
First, make sure you've [integrated Stripe into your app](https://docs.opensaas.sh/guides/stripe-integration/). This includes [installing the Stripe CLI and logging into it](https://docs.opensaas.sh/guides/stripe-testing/) with your Stripe account.
Next, Install the test dependencies:
```shell
cd e2e-tests && npm install
```
Start your Wasp DB and leave it running:
```shell
cd ../app && wasp db start
```
Open another terminal and start the Wasp app:
```shell
cd app && wasp start
```
In another terminal, run the local e2e tests:
```shell
cd e2e-tests && npm run local:e2e:start
```
This will start the tests in Playwright's UI mode, which will allow you to see and run the tests in an interactive browser environment. You should also see the Stripe events being triggered in the terminal where the tests were started.
To exit the local e2e tests, go back to the terminal were you started your tests and press `ctrl + c`.
## CI/CD
In `.github/workflows/e2e-tests.yml`, you can see the workflow that runs the headless e2e tests in a CI/CD pipeline via GitHub actions.
In order for these tests to run correctly, you need to provide the environment variables mentioned in the `e2e-tests.yml` file within your GitHub repository's "Actions" secrets so that they can be accessed by the tests.
Upon pushing to the repository's main branch, or creating a PR against the main branch, the tests will run in the CI/CD pipeline.

View File

@ -0,0 +1,41 @@
/**
* We use this script rather than running `wasp start` normally in the CI due to an error where prisma hangs due to some stdin issue.
* Running it this way gives us more control over that and avoids it hanging. See https://github.com/wasp-lang/wasp/pull/1218#issuecomment-1599098272.
*/
const cp = require('child_process');
const readline = require('linebyline');
function spawn(name, cmd, args, done) {
try {
const spawnOptions = {
detached: false,
};
const proc = cp.spawn(cmd, args, spawnOptions);
console.log('\x1b[0m\x1b[33m', `Starting ${name} with command: ${cmd} ${args.join(' ')}`, '\x1b[0m');
// We close stdin stream on the new process because otherwise the start-app
// process hangs.
// See https://github.com/wasp-lang/wasp/pull/1218#issuecomment-1599098272.
proc.stdin.destroy();
readline(proc.stdout).on('line', (data) => {
console.log(`\x1b[0m\x1b[33m[${name}][out]\x1b[0m ${data}`);
});
readline(proc.stderr).on('line', (data) => {
console.log(`\x1b[0m\x1b[33m[${name}][err]\x1b[0m ${data}`);
});
proc.on('exit', done);
} catch (error) {
console.error(error);
}
}
// Exit if either child fails
const cb = (code) => {
if (code !== 0) {
process.exit(code);
}
};
spawn('app', 'npm', ['run', 'ci:e2e:start-app'], cb);
spawn('db', 'npm', ['run', 'ci:e2e:start-db'], cb);

144
e2e-tests/package-lock.json generated Normal file
View File

@ -0,0 +1,144 @@
{
"name": "e2e-tests",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "e2e-tests",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@playwright/test": "^1.42.1",
"@prisma/client": "^4.16.2",
"linebyline": "^1.3.0",
"prisma": "^4.16.2"
},
"devDependencies": {
"@types/node": "^18.0.0"
}
},
"node_modules/@playwright/test": {
"version": "1.42.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.1.tgz",
"integrity": "sha512-Gq9rmS54mjBL/7/MvBaNOBwbfnh7beHvS6oS4srqXFcQHpQCV1+c8JXWE8VLPyRDhgS3H8x8A7hztqI9VnwrAQ==",
"dependencies": {
"playwright": "1.42.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@prisma/client": {
"version": "4.16.2",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.16.2.tgz",
"integrity": "sha512-qCoEyxv1ZrQ4bKy39GnylE8Zq31IRmm8bNhNbZx7bF2cU5aiCCnSa93J2imF88MBjn7J9eUQneNxUQVJdl/rPQ==",
"hasInstallScript": true,
"dependencies": {
"@prisma/engines-version": "4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81"
},
"engines": {
"node": ">=14.17"
},
"peerDependencies": {
"prisma": "*"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
}
}
},
"node_modules/@prisma/engines": {
"version": "4.16.2",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.16.2.tgz",
"integrity": "sha512-vx1nxVvN4QeT/cepQce68deh/Turxy5Mr+4L4zClFuK1GlxN3+ivxfuv+ej/gvidWn1cE1uAhW7ALLNlYbRUAw==",
"hasInstallScript": true
},
"node_modules/@prisma/engines-version": {
"version": "4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81.tgz",
"integrity": "sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg=="
},
"node_modules/@types/node": {
"version": "18.19.24",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.24.tgz",
"integrity": "sha512-eghAz3gnbQbvnHqB+mgB2ZR3aH6RhdEmHGS48BnV75KceQPHqabkxKI0BbUSsqhqy2Ddhc2xD/VAR9ySZd57Lw==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/linebyline": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/linebyline/-/linebyline-1.3.0.tgz",
"integrity": "sha512-3fpIYMrSU77OCf89hjXKuCx6vGwgWEu4N5DDCGqgZ1BF0HYy9V8IbQb/3+VWIU17iBQ83qQoUokH0AhPMOTi7w=="
},
"node_modules/playwright": {
"version": "1.42.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz",
"integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==",
"dependencies": {
"playwright-core": "1.42.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.42.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz",
"integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/prisma": {
"version": "4.16.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.16.2.tgz",
"integrity": "sha512-SYCsBvDf0/7XSJyf2cHTLjLeTLVXYfqp7pG5eEVafFLeT0u/hLFz/9W196nDRGUOo1JfPatAEb+uEnTQImQC1g==",
"hasInstallScript": true,
"dependencies": {
"@prisma/engines": "4.16.2"
},
"bin": {
"prisma": "build/index.js",
"prisma2": "build/index.js"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
}
}
}

34
e2e-tests/package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "e2e-tests",
"version": "1.0.0",
"description": "e2e-tests for opensaas.sh",
"main": "ci-start-app-and-db.js",
"directories": {
"test": "tests"
},
"scripts": {
"ci:e2e:start": "npm run ci:e2e:cleanup-db && node ci-start-app-and-db.js",
"ci:e2e:start-db": "cd ../app && wasp start db",
"ci:e2e:start-app": "npm run ci:e2e:wait-for-db && cd ../app && wasp db migrate-dev && cd ../e2e-tests && cd ../app && wasp start",
"ci:e2e:wait-for-db": "npx wait-port 5432",
"_comment-on-ci:e2e:cleanup-db": "NOTE: the name of the DB container, e.g. name=^wasp-dev-db-OpenSaaS-, is generated by wasp and will match the name of the app definition in your `main.wasp` file, e.g. `app OpenSaaS { }`",
"ci:e2e:cleanup-db": "(docker container rm $(docker container ls -f name=^wasp-dev-db-OpenSaaS- -q) -f || true) && docker volume rm $(docker volume ls -f name=^wasp-dev-db-OpenSaaS- -q) -f || true",
"e2e:playwright": "DEBUG=pw:webserver npx playwright test",
"_comment-on-local:e2e:cleanup-stripe": "NOTE: because we are running the stripe webhook listener in the background, we want to make sure we kill the previous processes before starting a new one.",
"local:e2e:cleanup-stripe": "PID=$(ps -ef | grep 'stripe listen' | grep -v grep | awk '{print $2}') || true && kill -9 $PID || true",
"local:e2e:start-stripe": "stripe listen --forward-to localhost:3001/stripe-webhook &",
"local:e2e:playwright:ui": "npx playwright test --ui",
"local:e2e:start": "npm run local:e2e:cleanup-stripe && npm run local:e2e:start-stripe && npm run local:e2e:playwright:ui && npm run local:e2e:cleanup-stripe"
},
"author": "",
"license": "ISC",
"dependencies": {
"@playwright/test": "^1.42.1",
"@prisma/client": "^4.16.2",
"linebyline": "^1.3.0",
"prisma": "^4.16.2"
},
"devDependencies": {
"@types/node": "^18.0.0"
}
}

View File

@ -0,0 +1,48 @@
import { defineConfig, devices } from '@playwright/test';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
outputDir: './test-results',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
/**
* `webServer` field tells Playwright how to run the app (webServer) it tests.
* It seems however that there is a bug in Playwright that keeps the web server open after running tests locally https://github.com/microsoft/playwright/issues/11907,
* causing errors when trying to run `wasp start` afterwards. To avoid this, we let Playwright run web server only in CI (where this is not a problem because after tests we don't do anything else).
* For local development, where this does pose a nuisance, we start the app / web server manually with `wasp db start` and `wasp start` and then start tests with `npm run local:e2e:start`.
*/
webServer: {
command: 'npm run ci:e2e:start',
// Wait for the backend to start
url: 'http://localhost:3001',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});

View File

@ -0,0 +1,154 @@
import { test, expect, type Page } from '@playwright/test';
import { signUserUp, logUserIn, createRandomUser, type User } from './utils';
let page: Page;
let testUser: User;
/**
* Each user is given 3 credits for free on signup. This will allow them to call the OpenAI API 3 times.
* After that, if they haven't paid, they will not be able to generate a schedule.
* In this test file, we run all tests sequentially so that we use up the user's first 3 credits
* and the 4th generation should fail. We can then test stripe payments and the ability to generate a schedule after payment.
*/
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
testUser = createRandomUser();
await signUserUp({ page: page, user: testUser });
await logUserIn({ page: page, user: testUser });
});
test.afterAll(async () => {
await page.close();
});
const task1 = 'create presentation on SaaS';
const task2 = 'build SaaS app draft';
test('User can make 3 AI schedule generations', async () => {
test.slow(); // Use a longer timeout time in case OpenAI is slow to respond
expect(page.url()).toContain('/demo-app');
await page.fill('input[id="description"]', task1);
await page.click('button:has-text("Add task")');
await expect(page.getByText(task1)).toBeVisible();
await page.fill('input[id="description"]', task2);
await page.click('button:has-text("Add task")');
await expect(page.getByText(task2)).toBeVisible();
for (let i = 0; i < 3; i++) {
const generateScheduleButton = page.getByRole('button', { name: 'Generate Schedule' });
await expect(generateScheduleButton).toBeVisible();
await Promise.all([
page.waitForRequest((req) => req.url().includes('operations/generate-gpt-response') && req.method() === 'POST'),
page.waitForResponse((response) => {
return response.url().includes('/operations/generate-gpt-response') && response.status() === 200;
}),
// We already started waiting before we perform the click that triggers the API calls. So now we just perform the click
generateScheduleButton.click(),
]);
// We already show a table with some dummy data even before the API call
// Now we want to check that the tasks we added are in the generated table
const table = page.getByRole('table');
await expect(table).toBeVisible();
const tableTextContent = (await table.innerText()).toLowerCase();
expect(tableTextContent.includes(task1.toLowerCase())).toBeTruthy();
expect(tableTextContent.includes(task2.toLowerCase())).toBeTruthy();
}
});
test('AI schedule generation fails on 4th attempt', async () => {
test.slow(); // Use a longer timeout time in case OpenAI is slow to respond
await page.reload();
const generateScheduleButton = page.getByRole('button', { name: 'Generate Schedule' });
await expect(generateScheduleButton).toBeVisible();
await Promise.all([
page.waitForRequest((req) => req.url().includes('operations/generate-gpt-response') && req.method() === 'POST'),
page.waitForResponse((response) => {
// expect the response to be 402 "PAYMENT_REQUIRED"
return response.url().includes('/operations/generate-gpt-response') && response.status() === 402;
}),
// We already started waiting before we perform the click that triggers the API calls. So now we just perform the click
generateScheduleButton.click(),
]);
// We already show a table with some dummy data even before the API call
// Now we want to check that the tasks were NOT added because the API call should have failed
const table = page.getByRole('table');
await expect(table).toBeVisible();
const tableTextContent = (await table.innerText()).toLowerCase();
expect(tableTextContent.includes(task1.toLowerCase())).toBeFalsy();
expect(tableTextContent.includes(task2.toLowerCase())).toBeFalsy();
});
test('Make test payment with Stripe', async () => {
const PLAN_NAME = 'Hobby';
test.slow(); // Stripe payments take a long time to confirm and can cause tests to fail so we use a longer timeout
await page.click('text="Pricing"');
await page.waitForURL('**/pricing');
const buyBtn = page.getByRole('button', { name: 'Buy plan' }).first();
await expect(buyBtn).toBeVisible();
await expect(buyBtn).toBeEnabled();
await buyBtn.click();
await page.waitForURL('https://checkout.stripe.com/**', { waitUntil: 'domcontentloaded' });
await page.fill('input[name="cardNumber"]', '4242424242424242');
await page.getByPlaceholder('MM / YY').fill('1225');
await page.getByPlaceholder('CVC').fill('123');
await page.getByPlaceholder('Full name on card').fill('Test User');
const countrySelect = page.getByLabel('Country or region');
await countrySelect.selectOption('Germany');
// This is a weird edge case where the `payBtn` assertion tests pass, but the button click still isn't registered.
// That's why we wait for stripe responses below to finish loading before clicking the button.
await page.waitForResponse(
(response) => response.url().includes('trusted-types-checker') && response.status() === 200
);
const payBtn = page.getByTestId('hosted-payment-submit-button');
await expect(payBtn).toBeVisible();
await expect(payBtn).toBeEnabled();
await payBtn.click();
await page.waitForURL('**/checkout?success=true');
await page.waitForURL('**/account');
await expect(page.getByText(PLAN_NAME)).toBeVisible();
});
test('User should be able to generate another schedule after payment', async () => {
await page.goto('/demo-app');
const generateScheduleButton = page.getByRole('button', { name: 'Generate Schedule' });
await expect(generateScheduleButton).toBeVisible();
await Promise.all([
page
.waitForRequest((req) => req.url().includes('operations/generate-gpt-response') && req.method() === 'POST')
.catch((err) => console.error(err.message)),
page
.waitForResponse((response) => {
if (response.url().includes('/operations/generate-gpt-response') && response.status() === 200) {
return true;
}
return false;
})
.catch((err) => console.error(err.message)),
// We already started waiting before we perform the click that triggers the API calls. So now we just perform the click
generateScheduleButton.click(),
]);
await page.waitForSelector('table');
const table = page.getByRole('table');
await expect(table).toBeVisible();
const tableTextContent = (await table.innerText()).toLowerCase();
expect(tableTextContent.includes(task1.toLowerCase())).toBeTruthy();
expect(tableTextContent.includes(task2.toLowerCase())).toBeTruthy();
});

View File

@ -0,0 +1,23 @@
import { test, expect } from '@playwright/test';
const DOCS_URL = 'https://docs.opensaas.sh'
test.describe('general landing page tests', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('has title', async ({ page }) => {
await expect(page).toHaveTitle(/SaaS/);
});
test('get started link', async ({ page }) => {
await page.getByRole('link', { name: 'Get started' }).click();
await page.waitForURL(DOCS_URL);
});
test('headings', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Frequently asked questions' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Some cool words' })).toBeVisible();
});
});

63
e2e-tests/tests/utils.ts Normal file
View File

@ -0,0 +1,63 @@
import { type Page } from '@playwright/test';
import { randomUUID } from 'crypto';
export type User = {
id?: number;
email: string;
password?: string;
};
const DEFAULT_PASSWORD = 'password123';
export const logUserIn = async ({ page, user }: { page: Page; user: User }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Log in' }).click();
await page.waitForURL('**/login', {
waitUntil: 'domcontentloaded',
});
await page.fill('input[name="email"]', user.email);
await page.fill('input[name="password"]', DEFAULT_PASSWORD);
const clickLogin = page.click('button:has-text("Log in")');
await Promise.all([
page
.waitForResponse((response) => {
return response.url().includes('login') && response.status() === 200;
})
.catch((err) => console.error(err.message)),
,
clickLogin,
]);
await page.waitForURL('**/demo-app');
};
export const signUserUp = async ({ page, user }: { page: Page; user: User }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Log in' }).click();
await page.click('text="go to signup"');
await page.fill('input[name="email"]', user.email);
await page.fill('input[name="password"]', DEFAULT_PASSWORD);
await page.click('button:has-text("Sign up")');
await page
.waitForResponse((response) => {
return response.url().includes('signup') && response.status() === 200;
})
.catch((err) => console.error(err.message));
};
export const createRandomUser = () => {
const email = `${randomUUID()}@test.com`;
return { email, password: DEFAULT_PASSWORD } as User;
};

10
e2e-tests/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"types": ["node"],
"module": "commonjs",
"target": "es5",
"noImplicitAny": false,
"sourceMap": false,
"lib": ["DOM", "ES2015"]
}
}