mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-04-11 13:29:13 +02:00
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:
parent
8fa8f24486
commit
2d94e28dd2
107
.github/workflows/e2e-tests.yml
vendored
Normal file
107
.github/workflows/e2e-tests.yml
vendored
Normal 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
30
.github/workflows/retag-commit.yml
vendored
Normal 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
2
.gitignore
vendored
@ -1,7 +1,7 @@
|
||||
*/.wasp/
|
||||
*/.env.server
|
||||
*/.env.client
|
||||
*/.DS_Store
|
||||
*/node_modules
|
||||
*/migrations
|
||||
*/.DS_Store
|
||||
.DS_Store
|
@ -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 :)
|
||||
|
11
README.md
11
README.md
@ -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)!
|
||||
|
@ -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...
|
||||
|
@ -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
6053
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
},
|
||||
|
@ -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
5
e2e-tests/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/reports/
|
||||
/test-results/
|
||||
/.cache/
|
||||
/node_modules/
|
||||
prisma/
|
41
e2e-tests/README.md
Normal file
41
e2e-tests/README.md
Normal 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.
|
41
e2e-tests/ci-start-app-and-db.js
Normal file
41
e2e-tests/ci-start-app-and-db.js
Normal 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
144
e2e-tests/package-lock.json
generated
Normal 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
34
e2e-tests/package.json
Normal 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"
|
||||
}
|
||||
}
|
48
e2e-tests/playwright.config.ts
Normal file
48
e2e-tests/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
154
e2e-tests/tests/demoAppTests.spec.ts
Normal file
154
e2e-tests/tests/demoAppTests.spec.ts
Normal 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();
|
||||
});
|
23
e2e-tests/tests/landingPageTests.spec.ts
Normal file
23
e2e-tests/tests/landingPageTests.spec.ts
Normal 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
63
e2e-tests/tests/utils.ts
Normal 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
10
e2e-tests/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"noImplicitAny": false,
|
||||
"sourceMap": false,
|
||||
"lib": ["DOM", "ES2015"]
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user