Repository: echobind/bisonapp
Pull request #295 (2022-12-11 02:15:29)

feat: Playwright > Cypress
![image](https://user-images.githubusercontent.com/17518011/206883317-6c255f0b-e709-4671-b1d7-333dfcff8483.png)

## Changes
- Takes all the Playwright setup and learnings from a previous project 
- Kills Cypress 
- New CI Flows 

## Checklist

- [x] Requires dependency update?
- [x] Generating a new app works



Patch:
diff --git a/.gitignore b/.gitignore
index 855ea90..ca47b20 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,6 @@
 node_modules
 .DS_Store
 
-# cypress
-cypress/videos*
-cypress/screenshots*
-
 # debug
 npm-debug.log*
 yarn-debug.log*
@@ -14,4 +10,4 @@ tmp
 lerna-debug.log
 .env
 
-packages/create-bison-app/dist/
\ No newline at end of file
+packages/create-bison-app/dist/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2883ce4..01608d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,14 @@
 All notable changes to this project will be documented in this file.
 See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
 
+## 1.13.0-canary.8 (2022-12-15)
+
+**Note:** Version bump only for package root
+
+
+
+
+
 ## 1.13.0-canary.7 (2022-12-13)
 
 **Note:** Version bump only for package root
diff --git a/lerna.json b/lerna.json
index 7fd52ba..b953ded 100644
--- a/lerna.json
+++ b/lerna.json
@@ -1,5 +1,5 @@
 {
-  "version": "1.13.0-canary.7",
+  "version": "1.13.0-canary.8",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "packages": [
diff --git a/packages/create-bison-app/CHANGELOG.md b/packages/create-bison-app/CHANGELOG.md
index 4dba12d..6f02e6b 100644
--- a/packages/create-bison-app/CHANGELOG.md
+++ b/packages/create-bison-app/CHANGELOG.md
@@ -3,6 +3,14 @@
 All notable changes to this project will be documented in this file.
 See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
 
+## 1.13.0-canary.8 (2022-12-15)
+
+**Note:** Version bump only for package create-bison-app
+
+
+
+
+
 ## 1.13.0-canary.7 (2022-12-13)
 
 **Note:** Version bump only for package create-bison-app
diff --git a/packages/create-bison-app/README.md b/packages/create-bison-app/README.md
index 89e9454..6243e5c 100644
--- a/packages/create-bison-app/README.md
+++ b/packages/create-bison-app/README.md
@@ -15,7 +15,7 @@ We're always improving on this, and we welcome suggestions from the community!
 - [Prisma](https://www.prisma.io/) w/ Postgres
 - [Chakra UI](https://chakra-ui.com/)
 - [React Hook Form](https://react-hook-form.com/)
-- [Cypress](https://www.cypress.io/) for E2E tests
+- [Playwright](https://www.playwright.dev/) for E2E tests
 - [React Testing Library](https://testing-library.com/docs/react-testing-library/intro) and [Jest](https://jestjs.io/) for unit and API request tests
 
 ## Features
diff --git a/packages/create-bison-app/package.json b/packages/create-bison-app/package.json
index 33a1725..cac7327 100644
--- a/packages/create-bison-app/package.json
+++ b/packages/create-bison-app/package.json
@@ -1,6 +1,6 @@
 {
   "name": "create-bison-app",
-  "version": "1.13.0-canary.7",
+  "version": "1.13.0-canary.8",
   "description": "Creates a production-ready full-stack Jamstack app",
   "license": "MIT",
   "repository": "echobind/bisonapp",
@@ -55,7 +55,6 @@
   "jest": {
     "testPathIgnorePatterns": [
       "node_modules",
-      "cypress",
       "template",
       "dist"
     ]
diff --git a/packages/create-bison-app/tasks/copyFiles.js b/packages/create-bison-app/tasks/copyFiles.js
index 1c67f54..6738ed8 100644
--- a/packages/create-bison-app/tasks/copyFiles.js
+++ b/packages/create-bison-app/tasks/copyFiles.js
@@ -55,7 +55,7 @@ async function copyFiles({ variables, targetFolder }) {
 
     copyWithTemplate(
       fromPath("_.env.development.local.ejs"),
-      toPath(".env.local"),
+      toPath(".env.development.local"),
       variables
     ),
 
@@ -94,7 +94,6 @@ async function copyFiles({ variables, targetFolder }) {
         "chakra",
         "components",
         "context",
-        "cypress",
         "layouts",
         "lib",
         "prisma",
@@ -109,8 +108,8 @@ async function copyFiles({ variables, targetFolder }) {
         ".nvmrc",
         ".tool-versions",
         "constants.ts",
-        "cypress.config.ts",
         "jest.config.js",
+        "playwright.config.ts",
         "next-env.d.ts",
         "prettier.config.js",
         "tsconfig.json",
diff --git a/packages/create-bison-app/template/.eslintrc.js b/packages/create-bison-app/template/.eslintrc.js
index b3bfec9..225259d 100644
--- a/packages/create-bison-app/template/.eslintrc.js
+++ b/packages/create-bison-app/template/.eslintrc.js
@@ -1,4 +1,4 @@
 module.exports = {
   extends: ['plugin:echobind/react'],
-  ignorePatterns: ['generated', 'node_modules/', 'types', 'cypress', 'scripts'],
+  ignorePatterns: ['generated', 'node_modules/', 'types', 'scripts'],
 };
diff --git a/packages/create-bison-app/template/.github/workflows/main.yml.ejs b/packages/create-bison-app/template/.github/workflows/main.yml.ejs
index 90da9fb..3acc87c 100644
--- a/packages/create-bison-app/template/.github/workflows/main.yml.ejs
+++ b/packages/create-bison-app/template/.github/workflows/main.yml.ejs
@@ -66,29 +66,20 @@ jobs:
           DATABASE_URL: postgresql://postgres:postgres@localhost/myapp_test
         run: yarn test:ci
 
-      # E2E Tests via Cypress
-      - name: Run Cypress E2E Tests
-        uses: cypress-io/github-action@v4
-        with:
-          # build: yarn build
-          start: yarn test:server
-          wait-on: 'http://localhost:3001'
+      # Install Playwright Browsers
+      - name: Install Playwright Browsers
+        run: npx playwright install --with-deps
+
+      # Playwright E2E tests
+      - name: E2E (Playwright)
         env:
           NODE_ENV: test
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           DATABASE_URL: postgresql://postgres:postgres@localhost/myapp_test
-
-      - name: Upload Cypress Screenshots
-        uses: actions/upload-artifact@v1
-        # Only capture images on failure
-        if: failure()
-        with:
-          name: cypress-screenshots
-          path: cypress/screenshots
-      - name: Upload Cypress Videos
-        uses: actions/upload-artifact@v1
-        # Test run video was always captured, so this action uses "always()" condition
+        timeout-minutes: 60
+        run: yarn test:e2e
+      - uses: actions/upload-artifact@v3
         if: always()
         with:
-          name: cypress-videos
-          path: cypress/videos
+          name: playwright-report
+          path: playwright-report/
+          retention-days: 30
diff --git a/packages/create-bison-app/template/.vscode/extensions.json b/packages/create-bison-app/template/.vscode/extensions.json
index 81ab96d..bcc4b09 100644
--- a/packages/create-bison-app/template/.vscode/extensions.json
+++ b/packages/create-bison-app/template/.vscode/extensions.json
@@ -7,7 +7,8 @@
     "pflannery.vscode-versionlens",
     "prisma.prisma",
     "DavidAnson.vscode-markdownlint",
-    "yzhang.markdown-all-in-one"
+    "yzhang.markdown-all-in-one",
+    "ms-playwright.playwright"
   ],
   "unwantedRecommendations": []
 }
diff --git a/packages/create-bison-app/template/_.env.development.ejs b/packages/create-bison-app/template/_.env.development.ejs
index 9524721..207dc1e 100644
--- a/packages/create-bison-app/template/_.env.development.ejs
+++ b/packages/create-bison-app/template/_.env.development.ejs
@@ -6,7 +6,14 @@
 # Prisma supports the native connection string format for PostgreSQL, MySQL and SQLite.
 # See the documentation for all the connection string options: https://pris.ly/d/connection-strings
 
+# Include NODE_ENV for package script withEnv to work as expected
+
+APP_ENV="development"
+NODE_ENV="development"
+PORT=3000
+BASE_URL="http://localhost:3000"
+
 DATABASE_URL="postgresql://<%= db.dev.user %><% if (db.dev.password) { %>:<%= db.dev.password %><% } %>@<%= db.dev.host %>:<%= db.dev.port %>/<%= db.dev.name %>?schema=public"
-NEXTAUTH_SECRET=foo
-NEXTAUTH_URL=http://localhost:3000
-APP_ENV=development
\ No newline at end of file
+
+NEXTAUTH_SECRET="bisonDev"
+NEXTAUTH_URL="http://localhost:3000"
\ No newline at end of file
diff --git a/packages/create-bison-app/template/_.env.development.local.ejs b/packages/create-bison-app/template/_.env.development.local.ejs
index bcc1edd..aac269b 100644
--- a/packages/create-bison-app/template/_.env.development.local.ejs
+++ b/packages/create-bison-app/template/_.env.development.local.ejs
@@ -1,6 +1,19 @@
-# ENV vars here override .env.development when running locally
+# Include your development ENVs here
+
+# Environment variables declared in this file are automatically made available to Prisma.
+# See the documentation for more detail: https://pris.ly/d/prisma-schema#using-environment-variables
+
+# Prisma supports the native connection string format for PostgreSQL, MySQL and SQLite.
+# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
+
+# Include NODE_ENV for package script withEnv to work as expected
+
+APP_ENV="development"
+NODE_ENV="development"
+PORT=3000
+BASE_URL="http://localhost:3000"
 
 DATABASE_URL="postgresql://<%= db.dev.user %><% if (db.dev.password) { %>:<%= db.dev.password %><% } %>@<%= db.dev.host %>:<%= db.dev.port %>/<%= db.dev.name %>?schema=public"
-NEXTAUTH_SECRET=foo
-NEXTAUTH_URL=http://localhost:3000
-APP_ENV=development
\ No newline at end of file
+
+NEXTAUTH_SECRET="bisonDev"
+NEXTAUTH_URL="http://localhost:3000"
\ No newline at end of file
diff --git a/packages/create-bison-app/template/_.env.ejs b/packages/create-bison-app/template/_.env.ejs
index 4074ece..d9dcaa7 100644
--- a/packages/create-bison-app/template/_.env.ejs
+++ b/packages/create-bison-app/template/_.env.ejs
@@ -6,7 +6,14 @@
 # Prisma supports the native connection string format for PostgreSQL, MySQL and SQLite.
 # See the documentation for all the connection string options: https://pris.ly/d/connection-strings
 
+# Include NODE_ENV for package script withEnv to work as expected
+
+APP_ENV="production"
+NODE_ENV="development" # production // change if you truly want PROD
+PORT=3000
+BASE_URL="http://localhost:3000"
+
 DATABASE_URL="postgresql://<%= db.dev.user %><% if (db.dev.password) { %>:<%= db.dev.password %><% } %>@<%= db.dev.host %>:<%= db.dev.port %>/<%= db.dev.name %>?schema=public"
-NEXTAUTH_SECRET=""
-NEXTAUTH_URL=""
-APP_ENV=production
\ No newline at end of file
+
+NEXTAUTH_SECRET="bisonProd"
+NEXTAUTH_URL="http://localhost:3000"
\ No newline at end of file
diff --git a/packages/create-bison-app/template/_.env.local.ejs b/packages/create-bison-app/template/_.env.local.ejs
index 1b013a6..1ebcb5e 100644
--- a/packages/create-bison-app/template/_.env.local.ejs
+++ b/packages/create-bison-app/template/_.env.local.ejs
@@ -1,6 +1,12 @@
-# ENV vars here override .env when running locally
+# Overwrite envs here to mimic production
+# Include NODE_ENV for package script withEnv to work as expected
+
+APP_ENV="production"
+NODE_ENV="development" # production // change if you truly want PROD
+PORT=3000
+BASE_URL="http://localhost:3000"
 
 DATABASE_URL="postgresql://<%= db.dev.user %><% if (db.dev.password) { %>:<%= db.dev.password %><% } %>@<%= db.dev.host %>:<%= db.dev.port %>/<%= db.dev.name %>?schema=public"
-NEXTAUTH_SECRET=""
-NEXTAUTH_URL=""
-APP_ENV=production
\ No newline at end of file
+
+NEXTAUTH_SECRET="bisonProd"
+NEXTAUTH_URL="http://localhost:3000"
\ No newline at end of file
diff --git a/packages/create-bison-app/template/_.gitignore b/packages/create-bison-app/template/_.gitignore
index e94d622..5946d99 100644
--- a/packages/create-bison-app/template/_.gitignore
+++ b/packages/create-bison-app/template/_.gitignore
@@ -34,9 +34,8 @@ yarn-error.log*
 
 .vercel
 
-cypress/videos*
-cypress/screenshots*
-cypress/logs*
-!cypress/videos/.gitkeep
-!cypress/screenshots/.gitkeep
-!cypress/logs/.gitkeep
\ No newline at end of file
+# Playwright
+/tests/e2e/temp/*.json
+/test-results/
+/playwright-report/
+/playwright/.cache/
\ No newline at end of file
diff --git a/packages/create-bison-app/template/components/auth/LoginForm.tsx b/packages/create-bison-app/template/components/auth/LoginForm.tsx
index afc8c27..ff9c3be 100644
--- a/packages/create-bison-app/template/components/auth/LoginForm.tsx
+++ b/packages/create-bison-app/template/components/auth/LoginForm.tsx
@@ -78,6 +78,7 @@ export function LoginForm() {
           <Input
             id="email"
             type="email"
+            data-testid="login-email"
             {...register('email', {
               required: 'Email is required.',
               pattern: {
@@ -92,6 +93,7 @@ export function LoginForm() {
         </FormControl>
         <PasswordField
           label="Password"
+          data-testid="login-password"
           isInvalid={Boolean(isSubmitted && errors.password)}
           {...register('password', {
             required: 'Password is required.',
@@ -110,7 +112,7 @@ export function LoginForm() {
         </NextLink>
       </HStack>
       <Stack spacing="6">
-        <Button variant="outline" type="submit" disabled={loading}>
+        <Button variant="outline" type="submit" disabled={loading} data-testid="login-submit">
           Sign in
         </Button>
         {signInError && <Text color="red.700">No User Found</Text>}
diff --git a/packages/create-bison-app/template/cypress.config.ts b/packages/create-bison-app/template/cypress.config.ts
deleted file mode 100644
index 303be7f..0000000
--- a/packages/create-bison-app/template/cypress.config.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { defineConfig } from 'cypress';
-
-import { setupNodeEvents } from './cypress/plugins';
-
-export default defineConfig({
-  fixturesFolder: 'cypress/fixtures',
-  e2e: {
-    setupNodeEvents,
-    baseUrl: 'http://localhost:3001',
-    specPattern: 'tests/e2e/**/*.cy.{ts,tsx}',
-    excludeSpecPattern: ['tsconfig.json'],
-    experimentalSessionAndOrigin: true,
-  },
-});
diff --git a/packages/create-bison-app/template/cypress/.eslintrc.json b/packages/create-bison-app/template/cypress/.eslintrc.json
deleted file mode 100644
index 589a598..0000000
--- a/packages/create-bison-app/template/cypress/.eslintrc.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-  "plugins": ["cypress"],
-  "env": {
-    "cypress/globals": true
-  },
-  "extends": ["plugin:cypress/recommended"]
-}
diff --git a/packages/create-bison-app/template/cypress/fixtures/credentials_error.json b/packages/create-bison-app/template/cypress/fixtures/credentials_error.json
deleted file mode 100644
index 20e2d74..0000000
--- a/packages/create-bison-app/template/cypress/fixtures/credentials_error.json
+++ /dev/null
@@ -1,1 +0,0 @@
-{ "url": "http://localhost:3000/api/auth/error?error=CredentialsSignin&provider=credentials" }
diff --git a/packages/create-bison-app/template/cypress/fixtures/credentials_success.json b/packages/create-bison-app/template/cypress/fixtures/credentials_success.json
deleted file mode 100644
index 61f5e7d..0000000
--- a/packages/create-bison-app/template/cypress/fixtures/credentials_success.json
+++ /dev/null
@@ -1,1 +0,0 @@
-{ "url": "http://localhost:3000" }
diff --git a/packages/create-bison-app/template/cypress/fixtures/session.json b/packages/create-bison-app/template/cypress/fixtures/session.json
deleted file mode 100644
index 80f6b8f..0000000
--- a/packages/create-bison-app/template/cypress/fixtures/session.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "user": {
-    "name": "Peter Parker",
-    "email": "peter@spidy.web",
-    "image": "/path/to/your/mock/user.jpg"
-  },
-  "accessToken": "accessToken",
-  "id_token": "id_token"
-}
diff --git a/packages/create-bison-app/template/cypress/fixtures/session_error.json b/packages/create-bison-app/template/cypress/fixtures/session_error.json
deleted file mode 100644
index a2ae651..0000000
--- a/packages/create-bison-app/template/cypress/fixtures/session_error.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
-  "error": "CredentialsSignin",
-  "status": 401,
-  "ok": false,
-  "url": null
-}
diff --git a/packages/create-bison-app/template/cypress/plugins/index.ts b/packages/create-bison-app/template/cypress/plugins/index.ts
deleted file mode 100644
index 9a6f022..0000000
--- a/packages/create-bison-app/template/cypress/plugins/index.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import path from 'path';
-
-import { Role } from '@prisma/client';
-
-import { resetDB, disconnect, setupDB, trpcRequest } from '@/tests/helpers';
-import * as Factories from '@/tests/factories';
-
-type FactoryNames = keyof typeof Factories extends `${infer T}Factory` ? T : never;
-
-if (process.env.CYPRESS_LOCAL) {
-  console.log('--- WARNING --- RUNNING CYPRESS IN LOCAL MODE... database will not be cleaned');
-
-  // make sure we source env.local
-  const envPath = path.resolve(process.cwd(), '.env.local');
-  require('dotenv').config({ path: envPath });
-} else if (!process.env.DATABASE_URL) {
-  const envPath = path.resolve(process.cwd(), '.env.test');
-  require('dotenv').config({ path: envPath });
-}
-
-export const setupNodeEvents: Cypress.ConfigOptions['setupNodeEvents'] = (on, _config) => {
-  on('before:run', async () => {
-    await setupDB();
-  });
-
-  on('task', {
-    resetDB: () => {
-      if (process.env.CYPRESS_LOCAL) return false;
-
-      return resetDB();
-    },
-    setupDB: () => {
-      console.log('Setting up DB', process.env.DATABASE_URL);
-
-      return setupDB();
-    },
-    disconnectDB: () => {
-      return disconnect();
-    },
-    factory: ({ name, attrs }: { name: FactoryNames; attrs: any }) => {
-      const Factory = Factories[`${name}Factory`];
-
-      return Factory.create(attrs);
-    },
-    mockValidSession: ({ user }: { user: NextAuthSession['user'] }): NextAuthSession => {
-      const isAdmin = user.roles.includes(Role.ADMIN);
-      return {
-        user,
-        isAdmin,
-        expires: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(),
-        idToken: 'id_token',
-        accessToken: 'accessToken',
-      };
-    },
-  });
-};
diff --git a/packages/create-bison-app/template/cypress/screenshots/.gitkeep b/packages/create-bison-app/template/cypress/screenshots/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/packages/create-bison-app/template/cypress/support/commands.ts b/packages/create-bison-app/template/cypress/support/commands.ts
deleted file mode 100644
index 46166c4..0000000
--- a/packages/create-bison-app/template/cypress/support/commands.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/// <reference types="cypress" />
-// ***********************************************
-// For comprehensive examples of custom
-// commands please read more here:
-// https://on.cypress.io/custom-commands
-// ***********************************************
-
-import '@testing-library/cypress/add-commands';
-
-import { Prisma, Profile, Role, User } from '@prisma/client';
-import { Session } from 'next-auth';
-
-declare global {
-  type PrismaUser = User & { roles: Role[]; profile: Profile | null };
-
-  // see next-auth.d.ts
-  interface NextAuthSession extends Session {
-    user: PrismaUser;
-    isAdmin: boolean;
-    idToken?: string;
-    accessToken?: string;
-  }
-
-  namespace Cypress {
-    interface Chainable {
-      login(user: NextAuthSession['user']): Chainable<NextAuthSession>;
-      createUserAndLogin(args?: Partial<Prisma.UserCreateInput>): Chainable<NextAuthSession>;
-    }
-  }
-}
-/**
- *  Handles logging a user in via email and password.
- *  This should be used to login in future e2e tests instead of the login form.
- */
-
-const login = (user: PrismaUser) => {
-  return cy.task<NextAuthSession>('mockValidSession', { user }).then((session) => {
-    cy.intercept('/api/auth/session', { body: session, statusCode: 200 }).as('session');
-
-    cy.intercept('/api/auth/callback/credentials?', {
-      fixture: 'credentials_success.json',
-    }).as('credentials');
-
-    // Make this "Chainable"
-    return Promise.resolve(session);
-  });
-};
-/**
- *  Handles creating and logging in a user with a set of attributes
- *  This should be used to login in future e2e tests instead of the login form.
- */
-const createUserAndLogin = (args: Partial<Prisma.UserCreateInput> = {}) => {
-  const attrs = {
-    ...args,
-    password: args?.password || 'abcd1234',
-  };
-
-  return cy
-    .task<NextAuthSession['user']>('factory', { name: 'User', attrs })
-    .then((user) => cy.login(user));
-};
-
-Cypress.Commands.add('login', login);
-Cypress.Commands.add('createUserAndLogin', createUserAndLogin);
diff --git a/packages/create-bison-app/template/cypress/tsconfig.json b/packages/create-bison-app/template/cypress/tsconfig.json
deleted file mode 100644
index 7c94e29..0000000
--- a/packages/create-bison-app/template/cypress/tsconfig.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "extends": "../tsconfig.json",
-  "compilerOptions": {
-    "target": "es2015",
-    "lib": ["es5", "dom"],
-    "types": ["cypress", "@testing-library/cypress", "node"]
-  },
-  "include": ["**/*.ts", "../cypress.config.ts"],
-  "exclude": []
-}
diff --git a/packages/create-bison-app/template/cypress/videos/.gitkeep b/packages/create-bison-app/template/cypress/videos/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/packages/create-bison-app/template/jest.config.js b/packages/create-bison-app/template/jest.config.js
index ada7b5a..4acb975 100644
--- a/packages/create-bison-app/template/jest.config.js
+++ b/packages/create-bison-app/template/jest.config.js
@@ -8,13 +8,7 @@ const moduleNameMapper = compilerOptions.paths
   ? pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/../' })
   : {};
 
-const testPathIgnorePatterns = [
-  '<rootDir>/node_modules',
-  'cypress',
-  'factories',
-  'helpers',
-  'tests/e2e',
-];
+const testPathIgnorePatterns = ['<rootDir>/node_modules', 'factories', 'helpers', 'tests/e2e'];
 
 /** @type {import('jest').Config} */
 module.exports = {
diff --git a/packages/create-bison-app/template/package.json.ejs b/packages/create-bison-app/template/package.json.ejs
index 551d0ed..c35c05a 100644
--- a/packages/create-bison-app/template/package.json.ejs
+++ b/packages/create-bison-app/template/package.json.ejs
@@ -9,12 +9,11 @@
     "build": "yarn ts-node ./scripts/buildProd",
     "build:prisma": "prisma generate",
     "build:next": "next build",
-    "cypress:open": "cypress open",
-    "cypress:run": "cypress run",
     "db:migrate": "prisma migrate dev",
     "db:migrate:prod": "prisma migrate deploy",
     "db:deploy": "yarn prisma migrate deploy",
     "db:reset": "yarn prisma migrate reset",
+    "db:reset:test": "yarn withEnv:test prisma migrate reset",
     "db:seed": "yarn prisma db seed",
     "db:seed:prod": "cross-env APP_ENV=production prisma db seed",
     "db:setup": "yarn db:reset",
@@ -28,16 +27,20 @@
     "g:test:factory": "hygen test factory --name",
     "g:test:request": "hygen test request --name",
     "g:test:util": "hygen test util --name",
+    "g:e2e": "yarn playwright codegen",
     "lint": "yarn eslint . --ext .ts,.tsx --ignore-pattern tmp",
     "lint:fix": "yarn lint --fix",
     "run:script": "yarn ts-node prisma/scripts/run.ts -f",
+    "setup": "yarn setup:dev && yarn setup:test",
     "setup:dev": "yarn build:prisma && yarn db:deploy && yarn db:seed",
+    "setup:test": "yarn withEnv:test -- yarn db:deploy",
     "start": "next start -p $PORT",
     "test": "yarn withEnv:test jest --runInBand --watch",
     "test:ci": "yarn withEnv:test jest --runInBand",
-    "test:e2e": "yarn withEnv:test $(yarn bin)/start-server-and-test 'yarn test:server' http://localhost:3001 'yarn cypress:run'",
-    "test:e2e:local": "yarn withEnv:test cross-env CYPRESS_LOCAL=true CYPRESS_BASE_URL=http://localhost:3000 cypress open",
-    "test:server": "next start --port 3001",
+    "test:db:migrate": "yarn withEnv:test prisma migrate dev",
+    "test:db:reset": "yarn withEnv:test prisma migrate reset --force",
+    "test:e2e": "yarn withEnv:test playwright test --workers 1",
+    "test:e2e:debug": "PWDEBUG=1 yarn test:e2e",
     "ts-node": "ts-node-dev --project tsconfig.cjs.json -r tsconfig-paths/register",
     "withEnv:test": "dotenv -c test --",
     "watch:ts": "yarn dev:typecheck --watch"
@@ -61,7 +64,7 @@
     "cross-fetch": "3.0.5",
     "framer-motion": "^4",
     "next": "12.1.5",
-    "next-auth": "^4.15.1",
+    "next-auth": "^4.18.6",
     "react": "18.2.0",
     "react-dom": "18.2.0",
     "react-hook-form": "^7.30.0",
@@ -72,8 +75,8 @@
     "zod": "^3.19.1"
   },
   "devDependencies": {
+    "@playwright/test": "^1.27.1",
     "@swc/jest": "^0.2.23",
-    "@testing-library/cypress": "^8.0.0",
     "@testing-library/dom": "^8.1.0",
     "@testing-library/jest-dom": "^5.14.1",
     "@testing-library/react": "^13.3.0",
@@ -88,13 +91,12 @@
     "chance": "^1.1.8",
     "commander": "^8.1.0",
     "cross-env": "^7.0.3",
-    "cypress": "^10.3.0",
     "dotenv-cli": "^6.0.0",
     "eslint": "^8.18.0",
     "eslint-config-next": "^12.1.6",
-    "eslint-plugin-cypress": "^2.12.1",
     "eslint-plugin-echobind": "^0.3.0",
     "eslint-plugin-import": "^2.26.0",
+    "eslint-plugin-playwright": "^0.11.2",
     "eslint-plugin-prettier": "^4.0.0",
     "husky": "^4.2.5",
     "hygen": "^6.0.4",
@@ -103,6 +105,7 @@
     "lint-staged": "^10.2.11",
     "nanoid": "^3.1.10",
     "pg": "^8.3.0",
+    "playwright": "^1.27.1",
     "prettier": "^2.6.2",
     "prisma": "^4.0.0",
     "start-server-and-test": "^1.14.0",
diff --git a/packages/create-bison-app/template/pages/api/auth/[...nextauth].ts b/packages/create-bison-app/template/pages/api/auth/[...nextauth].ts
index 41cff18..2133f1c 100644
--- a/packages/create-bison-app/template/pages/api/auth/[...nextauth].ts
+++ b/packages/create-bison-app/template/pages/api/auth/[...nextauth].ts
@@ -122,6 +122,22 @@ export const authOptions: NextAuthOptions = {
         return baseUrl;
       }
     },
+    async signIn({ user, account }) {
+      if (account?.type === 'credentials' && user.id) {
+        const isAllowedToSignIn = !!(account.providerAccountId === user.id);
+        return Promise.resolve(isAllowedToSignIn);
+      }
+
+      if (account?.provider === 'google' && user.id) {
+        // TODO: Session would need to include Account Info
+        // const isAllowedToSignIn = !!(account.providerAccountId === user.account.providerAccountId);
+        return Promise.resolve(true);
+      }
+
+      // Do different verification for other providers here or throw...
+
+      throw new Error('Not a known provider');
+    },
   },
 };
 
diff --git a/packages/create-bison-app/template/pages/index.tsx b/packages/create-bison-app/template/pages/index.tsx
index 3841756..f9806d7 100644
--- a/packages/create-bison-app/template/pages/index.tsx
+++ b/packages/create-bison-app/template/pages/index.tsx
@@ -17,7 +17,9 @@ function Home() {
       </Head>
 
       <Center>
-        <Heading size="lg">{welcomeMsg}</Heading>
+        <Heading size="lg" data-testid="welcome-header">
+          {welcomeMsg}
+        </Heading>
       </Center>
     </>
   );
diff --git a/packages/create-bison-app/template/playwright.config.ts b/packages/create-bison-app/template/playwright.config.ts
new file mode 100644
index 0000000..c0f8e91
--- /dev/null
+++ b/packages/create-bison-app/template/playwright.config.ts
@@ -0,0 +1,113 @@
+import type { PlaywrightTestConfig } from '@playwright/test';
+import { devices } from '@playwright/test';
+
+const TEST_SERVER_PORT = process.env.PORT ? Number(process.env.PORT) : 3001;
+const IS_CI = process.env.CI === 'true';
+
+/**
+ * Read environment variables from file.
+ * https://github.com/motdotla/dotenv
+ */
+// require('dotenv').config();
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+const config: PlaywrightTestConfig = {
+  globalSetup: require.resolve('./tests/e2e/global-setup'),
+  globalTeardown: require.resolve('./tests/e2e/global-teardown'),
+  testDir: './tests/e2e',
+  /* Maximum time one test can run for. */
+  timeout: 30 * 1000,
+  testMatch: '**/*.play.ts',
+  expect: {
+    /**
+     * Maximum time expect() should wait for the condition to be met.
+     * For example in `await expect(locator).toHaveText();`
+     */
+    timeout: 5000,
+  },
+  /* 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,
+  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+  reporter: 'html',
+  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+  use: {
+    /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
+    actionTimeout: 0,
+    /* Base URL to use in actions like `await page.goto('/')`. */
+    // baseURL: 'http://localhost:3001',
+
+    /* 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'],
+      },
+    },
+
+    {
+      name: 'firefox',
+      use: {
+        ...devices['Desktop Firefox'],
+      },
+    },
+
+    {
+      name: 'webkit',
+      use: {
+        ...devices['Desktop Safari'],
+      },
+    },
+
+    /* Test against mobile viewports. */
+    // {
+    //   name: 'Mobile Chrome',
+    //   use: {
+    //     ...devices['Pixel 5'],
+    //   },
+    // },
+    // {
+    //   name: 'Mobile Safari',
+    //   use: {
+    //     ...devices['iPhone 12'],
+    //   },
+    // },
+
+    /* Test against branded browsers. */
+    // {
+    //   name: 'Microsoft Edge',
+    //   use: {
+    //     channel: 'msedge',
+    //   },
+    // },
+    // {
+    //   name: 'Google Chrome',
+    //   use: {
+    //     channel: 'chrome',
+    //   },
+    // },
+  ],
+
+  /* Folder for test artifacts such as screenshots, videos, traces, etc. */
+  // outputDir: 'test-results/',
+
+  /* Run your local dev server before starting the tests */
+  webServer: {
+    command: IS_CI ? `yarn withEnv:test yarn start` : `yarn withEnv:test yarn dev`,
+    port: TEST_SERVER_PORT,
+  },
+};
+
+export default config;
diff --git a/packages/create-bison-app/template/tsconfig.json b/packages/create-bison-app/template/tsconfig.json
index ceab19c..a1a977e 100644
--- a/packages/create-bison-app/template/tsconfig.json
+++ b/packages/create-bison-app/template/tsconfig.json
@@ -1,11 +1,7 @@
 {
   "compilerOptions": {
     "target": "es2017",
-    "lib": [
-      "dom",
-      "dom.iterable",
-      "esnext"
-    ],
+    "lib": ["dom", "dom.iterable", "esnext"],
     "allowJs": true,
     "skipLibCheck": true,
     "strict": true,
@@ -19,10 +15,7 @@
     "isolatedModules": true,
     "jsx": "preserve",
     "sourceMap": true,
-    "typeRoots": [
-      "node_modules/@types",
-      "types"
-    ],
+    "typeRoots": ["node_modules/@types", "types"],
     "rootDir": ".",
     "baseUrl": ".",
     "paths": {
@@ -30,17 +23,6 @@
     },
     "incremental": true
   },
-  "include": [
-    ".",
-    "next-env.d.ts",
-    "types.d.ts",
-    "**/*.ts",
-    "**/*.tsx"
-  ],
-  "exclude": [
-    "node_modules",
-    "cypress",
-    "cypress.config.ts",
-    "**/*.cy.ts"
-  ]
+  "include": [".", "next-env.d.ts", "types.d.ts", "**/*.ts", "**/*.tsx"],
+  "exclude": ["node_modules", "**/*.cy.ts"]
 }


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 39b886e..4ef354e 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -88,32 +88,35 @@ jobs:
       - name: Install bison app packages
         run: yarn install --immutable
 
-      - name: Build production bison app
-        run: yarn build
-
-      - name: Lint bison app
-        run: yarn lint
-
       - name: Set up test database
         env:
           DATABASE_URL: postgresql://postgres:postgres@localhost/${{ needs.create-app.outputs.appName }}_test
           NODE_ENV: test
         run: yarn db:migrate
 
+      - name: Build production bison app
+        run: yarn build
+
+      - name: Lint bison app
+        run: yarn lint
+
       - name: Run bison app unit tests
         env:
           DATABASE_URL: postgresql://postgres:postgres@localhost/${{ needs.create-app.outputs.appName }}_test
           NODE_ENV: test
         run: yarn test:ci
 
-      - name: Run bison app E2E tests
-        uses: cypress-io/github-action@v4
-        with:
-          start: yarn test:server
-          wait-on: "http://localhost:3001"
+      - name: Install Playwright Browsers
+        run: npx playwright install --with-deps
+      - name: Run Playwright tests
         env:
           DATABASE_URL: postgresql://postgres:postgres@localhost/${{ needs.create-app.outputs.appName }}_test
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           NODE_ENV: test
-          NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
-          NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
+        run: yarn test:e2e
+      - uses: actions/upload-artifact@v3
+        if: always()
+        with:
+          name: playwright-report
+          path: playwright-report/
+          retention-days: 30
diff --git a/packages/create-bison-app/template/_.env.test.ejs b/packages/create-bison-app/template/_.env.test.ejs
index 8980676..8195a38 100644
--- a/packages/create-bison-app/template/_.env.test.ejs
+++ b/packages/create-bison-app/template/_.env.test.ejs
@@ -1,7 +1,12 @@
 # Copy to .env.test.local to override
+# Include NODE_ENV for package script withEnv to work as expected
+
+APP_ENV="test"
+NODE_ENV="test"
+PORT=3001
+BASE_URL="http://localhost:3001"
 
-# ENV vars here override .env when running tests
 DATABASE_URL="postgresql://<%= db.dev.user %><% if (db.dev.password) { %>:<%= db.dev.password %><% } %>@<%= db.dev.host %>:<%= db.dev.port %>/<%= db.test.name %>?schema=public"
-NEXTAUTH_SECRET=foo
-NEXTAUTH_URL=http://localhost:3001
-APP_ENV=test
\ No newline at end of file
+
+NEXTAUTH_SECRET="bisonTest"
+NEXTAUTH_URL="http://localhost:3001"
\ No newline at end of file
diff --git a/packages/create-bison-app/template/_.env.test.local.ejs b/packages/create-bison-app/template/_.env.test.local.ejs
index 6ea9993..06efb81 100644
--- a/packages/create-bison-app/template/_.env.test.local.ejs
+++ b/packages/create-bison-app/template/_.env.test.local.ejs
@@ -1,7 +1,12 @@
 # ENV vars here override .env.test when running locally
+# Include NODE_ENV for package script withEnv to work as expected
+
+APP_ENV="test"
+NODE_ENV="test"
+PORT=3001
+BASE_URL="http://localhost:3001"
 
-# ENV vars here override .env when running tests
 DATABASE_URL="postgresql://<%= db.dev.user %><% if (db.dev.password) { %>:<%= db.dev.password %><% } %>@<%= db.dev.host %>:<%= db.dev.port %>/<%= db.test.name %>?schema=public"
-NEXTAUTH_SECRET=foo
-NEXTAUTH_URL=http://localhost:3001
-APP_ENV=test
\ No newline at end of file
+
+NEXTAUTH_SECRET="bisonTest"
+NEXTAUTH_URL="http://localhost:3001"
\ No newline at end of file
diff --git a/packages/create-bison-app/template/cypress/support/e2e.ts b/packages/create-bison-app/template/cypress/support/e2e.ts
deleted file mode 100644
index e6ce68a..0000000
--- a/packages/create-bison-app/template/cypress/support/e2e.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-// ***********************************************************
-// This example support/e2e.ts is processed and
-// loaded automatically before your test files.
-//
-// This is a great place to put global configuration and
-// behavior that modifies Cypress.
-//
-// You can change the location of this file or turn off
-// automatically serving support files with the
-// 'supportFile' configuration option.
-//
-// You can read more here:
-// https://on.cypress.io/configuration
-// ***********************************************************
-import './commands';
-
-beforeEach(() => {
-  cy.task('resetDB');
-});
-
-after(() => {
-  cy.task('disconnectDB');
-});
diff --git a/packages/create-bison-app/template/tests/e2e/_.eslintrc.json b/packages/create-bison-app/template/tests/e2e/_.eslintrc.json
index 589a598..a97c975 100644
--- a/packages/create-bison-app/template/tests/e2e/_.eslintrc.json
+++ b/packages/create-bison-app/template/tests/e2e/_.eslintrc.json
@@ -1,7 +1,3 @@
 {
-  "plugins": ["cypress"],
-  "env": {
-    "cypress/globals": true
-  },
-  "extends": ["plugin:cypress/recommended"]
+  "extends": ["plugin:playwright/playwright-test"]
 }
diff --git a/packages/create-bison-app/template/tests/e2e/auth.play.ts b/packages/create-bison-app/template/tests/e2e/auth.play.ts
new file mode 100644
index 0000000..d7029a9
--- /dev/null
+++ b/packages/create-bison-app/template/tests/e2e/auth.play.ts
@@ -0,0 +1,31 @@
+// https://playwright.dev/docs/next/auth#multiple-signed-in-roles
+import { Page, test, expect } from '@playwright/test';
+
+import { ADMIN, APP_URL, USER } from './constants';
+
+test.describe(() => {
+  test.use({ storageState: USER.storageState });
+
+  test('Can Login as a User', async ({ page }: { page: Page }) => {
+    await page.goto(APP_URL);
+    await page.waitForURL((url) => url.origin === APP_URL, { waitUntil: 'networkidle' });
+    await page.waitForSelector('internal:attr=[data-testid="welcome-header"]');
+
+    const welcomeHeader = await page.getByTestId('welcome-header');
+    const welcomeMsg = `Welcome, ${USER.firstName}!`;
+    await expect(welcomeHeader).toContainText(welcomeMsg, { ignoreCase: true });
+  });
+});
+
+test.describe(() => {
+  test.use({ storageState: ADMIN.storageState });
+  test('Can Login as an Admin', async ({ page }: { page: Page }) => {
+    await page.goto(APP_URL);
+    await page.waitForURL((url) => url.origin === APP_URL, { waitUntil: 'networkidle' });
+    await page.waitForSelector('internal:attr=[data-testid="welcome-header"]');
+
+    const welcomeHeader = await page.getByTestId('welcome-header');
+    const welcomeMsg = `Welcome, ${ADMIN.firstName}!`;
+    await expect(welcomeHeader).toContainText(welcomeMsg, { ignoreCase: true });
+  });
+});
diff --git a/packages/create-bison-app/template/tests/e2e/constants.ts b/packages/create-bison-app/template/tests/e2e/constants.ts
new file mode 100644
index 0000000..102c51e
--- /dev/null
+++ b/packages/create-bison-app/template/tests/e2e/constants.ts
@@ -0,0 +1,19 @@
+export const APP_URL = process.env.BASE_URL || 'http://localhost:3001';
+export const LOGIN_URL = `${APP_URL}/login`;
+
+export const storageDir = './tests/e2e/temp';
+
+export const ADMIN = {
+  email: 'brainiac@lex.com',
+  firstName: 'Lex',
+  lastName: 'Luther',
+  password: '1amBrainiac!',
+  storageState: `${storageDir}/adminStorageState.json`,
+};
+export const USER = {
+  email: 'clark@thedaily.com',
+  firstName: 'Clark',
+  lastName: 'Kent',
+  password: 'krypton8!',
+  storageState: `${storageDir}/userStorageState.json`,
+};
diff --git a/packages/create-bison-app/template/tests/e2e/global-setup.ts b/packages/create-bison-app/template/tests/e2e/global-setup.ts
new file mode 100644
index 0000000..3754895
--- /dev/null
+++ b/packages/create-bison-app/template/tests/e2e/global-setup.ts
@@ -0,0 +1,77 @@
+import { chromium, FullConfig } from '@playwright/test';
+import { Prisma, Role } from '@prisma/client';
+
+import { UserFactory } from '../factories';
+
+import { ADMIN, APP_URL, LOGIN_URL, USER } from './constants';
+
+async function globalSetup(_config: FullConfig) {
+  // There's a case where a config/setup may fail in which case teardown doesn't fire
+  // Upsert here to avoid manual cleanup when testing locally.
+  const adminArgs: Prisma.UserCreateInput = {
+    email: ADMIN.email,
+    emailVerified: new Date().toISOString(),
+    roles: [Role.ADMIN],
+    password: ADMIN.password,
+    profile: {
+      create: {
+        firstName: ADMIN.firstName,
+        lastName: ADMIN.lastName,
+      },
+    },
+  };
+
+  const _adminUser = await UserFactory.upsert({
+    where: { email: ADMIN.email },
+    createArgs: adminArgs,
+    updateArgs: {
+      ...adminArgs,
+      profile: { update: { firstName: ADMIN.firstName, lastName: ADMIN.lastName } },
+    },
+  }).catch((e) => console.log({ e }));
+
+  const userArgs: Prisma.UserCreateInput = {
+    email: USER.email,
+    emailVerified: new Date().toISOString(),
+    roles: [Role.USER],
+    password: USER.password,
+    accounts: {},
+    profile: {
+      create: {
+        firstName: USER.firstName,
+        lastName: USER.lastName,
+      },
+    },
+  };
+
+  const _user = await UserFactory.upsert({
+    where: { email: USER.email },
+    createArgs: userArgs,
+    updateArgs: {
+      ...userArgs,
+      profile: { update: { firstName: USER.firstName, lastName: USER.lastName } },
+    },
+  }).catch((e) => console.log({ e }));
+
+  const browser = await chromium.launch();
+  const adminPage = await browser.newPage();
+  await adminPage.goto(LOGIN_URL);
+  await adminPage.getByTestId('login-email').fill(ADMIN.email);
+  await adminPage.getByTestId('login-password').fill(ADMIN.password);
+  await adminPage.getByTestId('login-submit').click();
+  await adminPage.waitForNavigation();
+  await adminPage.waitForURL((url) => url.origin === APP_URL, { waitUntil: 'networkidle' });
+  await adminPage.context().storageState({ path: ADMIN.storageState });
+
+  const userPage = await browser.newPage();
+  await userPage.goto(LOGIN_URL);
+  await userPage.getByTestId('login-email').fill(USER.email);
+  await userPage.getByTestId('login-password').fill(USER.password);
+  await userPage.getByTestId('login-submit').click();
+  await userPage.waitForNavigation();
+  await userPage.waitForURL((url) => url.origin === APP_URL, { waitUntil: 'networkidle' });
+  await userPage.context().storageState({ path: USER.storageState });
+  await browser.close();
+}
+
+export default globalSetup;
diff --git a/packages/create-bison-app/template/tests/e2e/global-teardown.ts b/packages/create-bison-app/template/tests/e2e/global-teardown.ts
new file mode 100644
index 0000000..a20ba20
--- /dev/null
+++ b/packages/create-bison-app/template/tests/e2e/global-teardown.ts
@@ -0,0 +1,10 @@
+import { FullConfig } from '@playwright/test';
+
+import { prisma } from '@/lib/prisma';
+
+async function globalTeardown(_config: FullConfig) {
+  prisma.$executeRaw`DROP SCHEMA public CASCADE`;
+  prisma.$executeRaw`CREATE SCHEMA public`;
+}
+
+export default globalTeardown;
diff --git a/packages/create-bison-app/template/tests/e2e/helpers.ts b/packages/create-bison-app/template/tests/e2e/helpers.ts
new file mode 100644
index 0000000..de96952
--- /dev/null
+++ b/packages/create-bison-app/template/tests/e2e/helpers.ts
@@ -0,0 +1,37 @@
+import { BrowserContext, Locator, Page } from '@playwright/test';
+
+import config from '@/playwright.config';
+
+export async function clickNewPage(
+  context: BrowserContext,
+  // the locator that, when clicked, will open a new page
+  clickableTarget: Locator
+): Promise<Page> {
+  const [newPage] = await Promise.all([
+    context.waitForEvent('page'),
+    // assume this opens the new tab
+    clickableTarget.click(),
+  ]);
+
+  // the page doesn't seem to inherit the timeout from context, so setting it
+  // here too
+  newPage.setDefaultTimeout(config.timeout || 30000);
+  newPage.setDefaultNavigationTimeout(config.timeout || 30000);
+
+  await newPage.waitForLoadState();
+
+  return newPage;
+}
+
+export async function uploadFile(page: Page, clickableTarget: Locator, filePath: string) {
+  // Note that Promise.all prevents a race condition
+  // between clicking and waiting for the file chooser.
+  const [fileChooser] = await Promise.all([
+    // It is important to call waitForEvent before click to set up waiting.
+    page.waitForEvent('filechooser'),
+    // Opens the file chooser.
+    clickableTarget.click(),
+  ]);
+
+  await fileChooser.setFiles(filePath);
+}
diff --git a/packages/create-bison-app/template/tests/e2e/login.cy.ts b/packages/create-bison-app/template/tests/e2e/login.cy.ts
deleted file mode 100644
index ff82c04..0000000
--- a/packages/create-bison-app/template/tests/e2e/login.cy.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-describe('Login', () => {
-  describe('with an email that doesnt exist', () => {
-    it('shows an error', () => {
-      const email = 'nowayshouldIexist@wee.net';
-      const password = 'test1234';
-      cy.intercept('/api/auth/session', { fixture: 'session_error.json' }).as('session');
-      cy.intercept('/api/auth/callback/credentials?', { fixture: 'credentials_error.json' }).as(
-        'credentials'
-      );
-
-      cy.visit('/');
-      cy.findByText(/login/i).click();
-
-      cy.location('pathname').should('equal', '/login');
-      cy.findByLabelText(/email/i).type(email);
-      cy.get('input[name=password]').type(password);
-      cy.findAllByRole('button', { name: /sign in/i }).click();
-
-      cy.wait('@session');
-      cy.wait('@credentials');
-
-      cy.findByText(/No User Found/i).should('exist');
-    });
-  });
-
-  describe('with valid credentials', () => {
-    it('logs in', () => {
-      const attrs = {
-        password: 'superawesome',
-        profile: { create: { firstName: 'Peter', lastName: 'Parker' } },
-      };
-
-      // note: async/await breaks cypress 😭
-      cy.task<PrismaUser>('factory', { name: 'User', attrs }).then((user) => {
-        cy.task<NextAuthSession>('mockValidSession', { user }).then((session) => {
-          const welcomeMsg = `Welcome, ${session.user.profile?.firstName}!`;
-          cy.intercept('/api/auth/session', { body: session, statusCode: 200 }).as('session');
-          cy.intercept('/api/auth/callback/credentials?', {
-            fixture: 'credentials_success.json',
-          }).as('credentials');
-
-          cy.visit('/');
-          cy.findByText(/login/i).click();
-
-          cy.location('pathname').should('equal', '/login');
-          cy.findByLabelText(/email/i).type(user.email);
-          cy.get('input[name=password]').type('superawesome');
-          cy.findAllByRole('button', { name: /sign in/i }).click();
-
-          cy.wait('@session');
-          cy.wait('@credentials');
-
-          cy.contains(welcomeMsg);
-          cy.findByText(/logout/i).should('exist');
-          cy.findByText(/logout/i).click();
-
-          cy.location('pathname').should('equal', '/');
-
-          const guestMsg = 'Welcome, Guest!';
-          cy.contains(guestMsg);
-        });
-      });
-    });
-  });
-});
diff --git a/packages/create-bison-app/template/tests/e2e/logout.cy.ts b/packages/create-bison-app/template/tests/e2e/logout.cy.ts
deleted file mode 100644
index 26cc01d..0000000
--- a/packages/create-bison-app/template/tests/e2e/logout.cy.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-describe('Logout', () => {
-  it('properly logs out a user', () => {
-    cy.createUserAndLogin().then((session) => {
-      const welcomeMsg = `Welcome, ${session.user.profile?.firstName}!`;
-      cy.visit('/');
-      // Verify signed in as User
-      cy.contains(welcomeMsg);
-
-      cy.wait('@session').then(() => {
-        cy.findByText(/logout/i).click();
-        // No way to intercept once for logout, kill the previous session from login
-        // This is hacky but all other specs will assume a user is logged in -- or no session at all.
-        // Also we are not spending forever here -- looking at you Playwright...
-        cy.intercept('/api/auth/session', { fixture: 'session_error.json', statusCode: 401 }).as(
-          'voidSession'
-        );
-
-        const guestMsg = 'Welcome, Guest!';
-        cy.contains(guestMsg);
-        cy.contains(/login/i);
-      });
-    });
-  });
-});
diff --git a/packages/create-bison-app/template/tests/e2e/playwrightExamples/demo-todo-app.play.ts b/packages/create-bison-app/template/tests/e2e/playwrightExamples/demo-todo-app.play.ts
new file mode 100644
index 0000000..5cb4ffb
--- /dev/null
+++ b/packages/create-bison-app/template/tests/e2e/playwrightExamples/demo-todo-app.play.ts
@@ -0,0 +1,429 @@
+import { test, expect, type Page } from '@playwright/test';
+
+test.beforeEach(async ({ page }) => {
+  await page.goto('https://demo.playwright.dev/todomvc');
+});
+
+const TODO_ITEMS = ['buy some cheese', 'feed the cat', 'book a doctors appointment'];
+
+test.describe('New Todo', () => {
+  test('should allow me to add todo items', async ({ page }) => {
+    // create a new todo locator
+    const newTodo = page.getByPlaceholder('What needs to be done?');
+
+    // Create 1st todo.
+    await newTodo.fill(TODO_ITEMS[0]);
+    await newTodo.press('Enter');
+
+    // Make sure the list only has one todo item.
+    await expect(page.getByTestId('todo-title')).toHaveText([TODO_ITEMS[0]]);
+
+    // Create 2nd todo.
+    await newTodo.fill(TODO_ITEMS[1]);
+    await newTodo.press('Enter');
+
+    // Make sure the list now has two todo items.
+    await expect(page.getByTestId('todo-title')).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
+
+    await checkNumberOfTodosInLocalStorage(page, 2);
+  });
+
+  test('should clear text input field when an item is added', async ({ page }) => {
+    // create a new todo locator
+    const newTodo = page.getByPlaceholder('What needs to be done?');
+
+    // Create one todo item.
+    await newTodo.fill(TODO_ITEMS[0]);
+    await newTodo.press('Enter');
+
+    // Check that input is empty.
+    await expect(newTodo).toBeEmpty();
+    await checkNumberOfTodosInLocalStorage(page, 1);
+  });
+
+  test('should append new items to the bottom of the list', async ({ page }) => {
+    // Create 3 items.
+    await createDefaultTodos(page);
+
+    // create a todo count locator
+    const todoCount = page.getByTestId('todo-count');
+
+    // Check test using different methods.
+    await expect(page.getByText('3 items left')).toBeVisible();
+    await expect(todoCount).toHaveText('3 items left');
+    await expect(todoCount).toContainText('3');
+    await expect(todoCount).toHaveText(/3/);
+
+    // Check all items in one call.
+    await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS);
+    await checkNumberOfTodosInLocalStorage(page, 3);
+  });
+});
+
+test.describe('Mark all as completed', () => {
+  test.beforeEach(async ({ page }) => {
+    await createDefaultTodos(page);
+    await checkNumberOfTodosInLocalStorage(page, 3);
+  });
+
+  test.afterEach(async ({ page }) => {
+    await checkNumberOfTodosInLocalStorage(page, 3);
+  });
+
+  test('should allow me to mark all items as completed', async ({ page }) => {
+    // Complete all todos.
+    await page.getByLabel('Mark all as complete').check();
+
+    // Ensure all todos have 'completed' class.
+    await expect(page.getByTestId('todo-item')).toHaveClass([
+      'completed',
+      'completed',
+      'completed',
+    ]);
+
+    await checkNumberOfCompletedTodosInLocalStorage(page, 3);
+  });
+
+  test('should allow me to clear the complete state of all items', async ({ page }) => {
+    const toggleAll = page.getByLabel('Mark all as complete');
+    // Check and then immediately uncheck.
+    await toggleAll.check();
+    await toggleAll.uncheck();
+
+    // Should be no completed classes.
+    await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']);
+  });
+
+  test('complete all checkbox should update state when items are completed / cleared', async ({
+    page,
+  }) => {
+    const toggleAll = page.getByLabel('Mark all as complete');
+    await toggleAll.check();
+    await expect(toggleAll).toBeChecked();
+    await checkNumberOfCompletedTodosInLocalStorage(page, 3);
+
+    // Uncheck first todo.
+    const firstTodo = page.getByTestId('todo-item').nth(0);
+    await firstTodo.getByRole('checkbox').uncheck();
+
+    // Reuse toggleAll locator and make sure its not checked.
+    await expect(toggleAll).not.toBeChecked();
+
+    await firstTodo.getByRole('checkbox').check();
+    await checkNumberOfCompletedTodosInLocalStorage(page, 3);
+
+    // Assert the toggle all is checked again.
+    await expect(toggleAll).toBeChecked();
+  });
+});
+
+test.describe('Item', () => {
+  test('should allow me to mark items as complete', async ({ page }) => {
+    // create a new todo locator
+    const newTodo = page.getByPlaceholder('What needs to be done?');
+
+    // Create two items.
+    for (const item of TODO_ITEMS.slice(0, 2)) {
+      await newTodo.fill(item);
+      await newTodo.press('Enter');
+    }
+
+    // Check first item.
+    const firstTodo = page.getByTestId('todo-item').nth(0);
+    await firstTodo.getByRole('checkbox').check();
+    await expect(firstTodo).toHaveClass('completed');
+
+    // Check second item.
+    const secondTodo = page.getByTestId('todo-item').nth(1);
+    await expect(secondTodo).not.toHaveClass('completed');
+    await secondTodo.getByRole('checkbox').check();
+
+    // Assert completed class.
+    await expect(firstTodo).toHaveClass('completed');
+    await expect(secondTodo).toHaveClass('completed');
+  });
+
+  test('should allow me to un-mark items as complete', async ({ page }) => {
+    // create a new todo locator
+    const newTodo = page.getByPlaceholder('What needs to be done?');
+
+    // Create two items.
+    for (const item of TODO_ITEMS.slice(0, 2)) {
+      await newTodo.fill(item);
+      await newTodo.press('Enter');
+    }
+
+    const firstTodo = page.getByTestId('todo-item').nth(0);
+    const secondTodo = page.getByTestId('todo-item').nth(1);
+    const firstTodoCheckbox = firstTodo.getByRole('checkbox');
+
+    await firstTodoCheckbox.check();
+    await expect(firstTodo).toHaveClass('completed');
+    await expect(secondTodo).not.toHaveClass('completed');
+    await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+
+    await firstTodoCheckbox.uncheck();
+    await expect(firstTodo).not.toHaveClass('completed');
+    await expect(secondTodo).not.toHaveClass('completed');
+    await checkNumberOfCompletedTodosInLocalStorage(page, 0);
+  });
+
+  test('should allow me to edit an item', async ({ page }) => {
+    await createDefaultTodos(page);
+
+    const todoItems = page.getByTestId('todo-item');
+    const secondTodo = todoItems.nth(1);
+    await secondTodo.dblclick();
+    await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]);
+    await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
+    await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter');
+
+    // Explicitly assert the new text value.
+    await expect(todoItems).toHaveText([TODO_ITEMS[0], 'buy some sausages', TODO_ITEMS[2]]);
+
+    await checkTodosInLocalStorage(page, 'buy some sausages');
+  });
+});
+
+test.describe('Editing', () => {
+  test.beforeEach(async ({ page }) => {
+    await createDefaultTodos(page);
+    await checkNumberOfTodosInLocalStorage(page, 3);
+  });
+
+  test('should hide other controls when editing', async ({ page }) => {
+    const todoItem = page.getByTestId('todo-item').nth(1);
+    await todoItem.dblclick();
+    await expect(todoItem.getByRole('checkbox')).toBeHidden();
+    await expect(
+      todoItem.locator('label', {
+        hasText: TODO_ITEMS[1],
+      })
+    ).toBeHidden();
+
+    await checkNumberOfTodosInLocalStorage(page, 3);
+  });
+
+  test('should save edits on blur', async ({ page }) => {
+    const todoItems = page.getByTestId('todo-item');
+    await todoItems.nth(1).dblclick();
+    await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
+    await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur');
+
+    await expect(todoItems).toHaveText([TODO_ITEMS[0], 'buy some sausages', TODO_ITEMS[2]]);
+
+    await checkTodosInLocalStorage(page, 'buy some sausages');
+  });
+
+  test('should trim entered text', async ({ page }) => {
+    const todoItems = page.getByTestId('todo-item');
+    await todoItems.nth(1).dblclick();
+    await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('    buy some sausages    ');
+    await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
+
+    await expect(todoItems).toHaveText([TODO_ITEMS[0], 'buy some sausages', TODO_ITEMS[2]]);
+
+    await checkTodosInLocalStorage(page, 'buy some sausages');
+  });
+
+  test('should remove the item if an empty text string was entered', async ({ page }) => {
+    const todoItems = page.getByTestId('todo-item');
+    await todoItems.nth(1).dblclick();
+    await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('');
+    await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
+
+    await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
+  });
+
+  test('should cancel edits on escape', async ({ page }) => {
+    const todoItems = page.getByTestId('todo-item');
+    await todoItems.nth(1).dblclick();
+    await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
+    await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape');
+    await expect(todoItems).toHaveText(TODO_ITEMS);
+  });
+});
+
+test.describe('Counter', () => {
+  test('should display the current number of todo items', async ({ page }) => {
+    // create a new todo locator
+    const newTodo = page.getByPlaceholder('What needs to be done?');
+
+    // create a todo count locator
+    const todoCount = page.getByTestId('todo-count');
+
+    await newTodo.fill(TODO_ITEMS[0]);
+    await newTodo.press('Enter');
+
+    await expect(todoCount).toContainText('1');
+
+    await newTodo.fill(TODO_ITEMS[1]);
+    await newTodo.press('Enter');
+    await expect(todoCount).toContainText('2');
+
+    await checkNumberOfTodosInLocalStorage(page, 2);
+  });
+});
+
+test.describe('Clear completed button', () => {
+  test.beforeEach(async ({ page }) => {
+    await createDefaultTodos(page);
+  });
+
+  test('should display the correct text', async ({ page }) => {
+    await page.locator('.todo-list li .toggle').first().check();
+    await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible();
+  });
+
+  test('should remove completed items when clicked', async ({ page }) => {
+    const todoItems = page.getByTestId('todo-item');
+    await todoItems.nth(1).getByRole('checkbox').check();
+    await page.getByRole('button', { name: 'Clear completed' }).click();
+    await expect(todoItems).toHaveCount(2);
+    await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
+  });
+
+  test('should be hidden when there are no items that are completed', async ({ page }) => {
+    await page.locator('.todo-list li .toggle').first().check();
+    await page.getByRole('button', { name: 'Clear completed' }).click();
+    await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden();
+  });
+});
+
+test.describe('Persistence', () => {
+  test('should persist its data', async ({ page }) => {
+    // create a new todo locator
+    const newTodo = page.getByPlaceholder('What needs to be done?');
+
+    for (const item of TODO_ITEMS.slice(0, 2)) {
+      await newTodo.fill(item);
+      await newTodo.press('Enter');
+    }
+
+    const todoItems = page.getByTestId('todo-item');
+    const firstTodoCheck = todoItems.nth(0).getByRole('checkbox');
+    await firstTodoCheck.check();
+    await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
+    await expect(firstTodoCheck).toBeChecked();
+    await expect(todoItems).toHaveClass(['completed', '']);
+
+    // Ensure there is 1 completed item.
+    await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+
+    // Now reload.
+    await page.reload();
+    await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
+    await expect(firstTodoCheck).toBeChecked();
+    await expect(todoItems).toHaveClass(['completed', '']);
+  });
+});
+
+test.describe('Routing', () => {
+  test.beforeEach(async ({ page }) => {
+    await createDefaultTodos(page);
+    // make sure the app had a chance to save updated todos in storage
+    // before navigating to a new view, otherwise the items can get lost :(
+    // in some frameworks like Durandal
+    await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
+  });
+
+  test('should allow me to display active items', async ({ page }) => {
+    const todoItem = page.getByTestId('todo-item');
+    await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
+
+    await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+    await page.getByRole('link', { name: 'Active' }).click();
+    await expect(todoItem).toHaveCount(2);
+    await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
+  });
+
+  test('should respect the back button', async ({ page }) => {
+    const todoItem = page.getByTestId('todo-item');
+    await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
+
+    await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+
+    await test.step('Showing all items', async () => {
+      await page.getByRole('link', { name: 'All' }).click();
+      await expect(todoItem).toHaveCount(3);
+    });
+
+    await test.step('Showing active items', async () => {
+      await page.getByRole('link', { name: 'Active' }).click();
+    });
+
+    await test.step('Showing completed items', async () => {
+      await page.getByRole('link', { name: 'Completed' }).click();
+    });
+
+    await expect(todoItem).toHaveCount(1);
+    await page.goBack();
+    await expect(todoItem).toHaveCount(2);
+    await page.goBack();
+    await expect(todoItem).toHaveCount(3);
+  });
+
+  test('should allow me to display completed items', async ({ page }) => {
+    await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
+    await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+    await page.getByRole('link', { name: 'Completed' }).click();
+    await expect(page.getByTestId('todo-item')).toHaveCount(1);
+  });
+
+  test('should allow me to display all items', async ({ page }) => {
+    await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
+    await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+    await page.getByRole('link', { name: 'Active' }).click();
+    await page.getByRole('link', { name: 'Completed' }).click();
+    await page.getByRole('link', { name: 'All' }).click();
+    await expect(page.getByTestId('todo-item')).toHaveCount(3);
+  });
+
+  test('should highlight the currently applied filter', async ({ page }) => {
+    await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected');
+
+    //create locators for active and completed links
+    const activeLink = page.getByRole('link', { name: 'Active' });
+    const completedLink = page.getByRole('link', { name: 'Completed' });
+    await activeLink.click();
+
+    // Page change - active items.
+    await expect(activeLink).toHaveClass('selected');
+    await completedLink.click();
+
+    // Page change - completed items.
+    await expect(completedLink).toHaveClass('selected');
+  });
+});
+
+async function createDefaultTodos(page: any) {
+  // create a new todo locator
+  const newTodo = page.getByPlaceholder('What needs to be done?');
+
+  for (const item of TODO_ITEMS) {
+    await newTodo.fill(item);
+    await newTodo.press('Enter');
+  }
+}
+
+async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
+  return await page.waitForFunction((e) => {
+    return JSON.parse(localStorage['react-todos']).length === e;
+  }, expected);
+}
+
+async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) {
+  return await page.waitForFunction((e) => {
+    return (
+      JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e
+    );
+  }, expected);
+}
+
+async function checkTodosInLocalStorage(page: Page, title: string) {
+  return await page.waitForFunction((t) => {
+    return JSON.parse(localStorage['react-todos'])
+      .map((todo: any) => todo.title)
+      .includes(t);
+  }, title);
+}
diff --git a/packages/create-bison-app/template/tests/e2e/playwrightExamples/example.play.ts b/packages/create-bison-app/template/tests/e2e/playwrightExamples/example.play.ts
new file mode 100644
index 0000000..16df838
--- /dev/null
+++ b/packages/create-bison-app/template/tests/e2e/playwrightExamples/example.play.ts
@@ -0,0 +1,20 @@
+import { test, expect } from '@playwright/test';
+
+test('homepage has title and links to intro page', async ({ page }) => {
+  await page.goto('https://playwright.dev/');
+
+  // Expect a title "to contain" a substring.
+  await expect(page).toHaveTitle(/Playwright/);
+
+  // create a locator
+  const getStarted = page.getByRole('link', { name: 'Get started' });
+
+  // Expect an attribute "to be strictly equal" to the value.
+  await expect(getStarted).toHaveAttribute('href', '/docs/intro');
+
+  // Click the get started link.
+  await getStarted.click();
+
+  // Expects the URL to contain intro.
+  await expect(page).toHaveURL(/.*intro/);
+});
diff --git a/packages/create-bison-app/template/cypress/logs/.gitkeep b/packages/create-bison-app/template/tests/e2e/temp/.gitkeep
similarity index 100%
rename from packages/create-bison-app/template/cypress/logs/.gitkeep
rename to packages/create-bison-app/template/tests/e2e/temp/.gitkeep
diff --git a/packages/create-bison-app/template/tests/e2e/temp/adminStorageState.json b/packages/create-bison-app/template/tests/e2e/temp/adminStorageState.json
new file mode 100644
index 0000000..d0e5f08
--- /dev/null
+++ b/packages/create-bison-app/template/tests/e2e/temp/adminStorageState.json
@@ -0,0 +1,49 @@
+{
+  "cookies": [
+    {
+      "name": "next-auth.csrf-token",
+      "value": "c6f90b4879ba7065866c7e205a935c5017630a72d5fa57c0cd29f8af0ac04611%7C0b621e4d6291fc41671ffcb6fc65869ad9faa9f8e190f9f3f889256a0e67d5e0",
+      "domain": "localhost",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": true,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "next-auth.callback-url",
+      "value": "http%3A%2F%2Flocalhost%3A3001",
+      "domain": "localhost",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": true,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "next-auth.session-token",
+      "value": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..w5SMOHrke8sre-Mb.8-cQwbtrAjy9y1HMWxQN5uV_say0gejeNbDh3WiDtyhBRC8M3DupI9OqbTzCEnKNIujf0t1s5BcCMwiB0y20WT65zMMulY0vjGGu2IABFw_5IZsaKQPLNcWcINEIxKTGr5nkWYaGHjxlCFRtyfyXkskJOgbY800aNOuTdv7EAz7NTZAKj33RSTyw68ZTw1X3Y2hkESlwHSKXtZqDrfKLneit-1zX4IVD78zkBJzL93hOCNQdesPxntaUWTHX4FnUlSu42_xjAZ5tTu7OLBYBCfuRC4P-kzJrr-QQCcL8sQSZASpqNqdzyCXgK-A1AbO3DaGKY2RnC-gQGQSZ_9WjXP6UVjfzf2oDyLOrmpGAQm_166o-gofeytiumFjstk31-w6yQNdP-5G_oAaONdl7OyW9VON-Q85uq7W3AbayouExecuVuMEf8KlRqSUdEAwdGxKEXYQv3xkItVf-jQ6jBiB_F4Av5ACdEmjdCySlcKdyoz3p_5JR7pS7pzXFQdVUoYuQniR2rCJQ6spyxG0WR5HpSdTFNFi34uE5o9Oi9gznFcos56AseRDxxDF4jFfi8lMxcmRgY7xC8L5d91IsvuW6W7V-r6LvsC1hZDUSZH5lOuHhcITXi-A5h-3HcsaWN3vTH_-CgjTnPjxxVpY8IQtKYFdDTDpmuwVTwg3qLPTsXrJv0k02CbANND_LVurMUF2YWNn7nHOBRmy0G14z6TY-RPZ_u36Vh-nX6FfsARS3ZomI2Q2nwbODUdaebCESGVsD14VgsJkLeVQpp3q73Vgsauo5rHYlIEzgSYL4AtT2RLjeMOhJw7M223CEgjMCOblD4sfcnl40AA.AGtYDlc2VrLhnBg6o-5L-Q",
+      "domain": "localhost",
+      "path": "/",
+      "expires": 1672518307.237139,
+      "httpOnly": true,
+      "secure": false,
+      "sameSite": "Lax"
+    }
+  ],
+  "origins": [
+    {
+      "origin": "http://localhost:3001",
+      "localStorage": [
+        {
+          "name": "nextauth.message",
+          "value": "{\"event\":\"session\",\"data\":{\"trigger\":\"getSession\"},\"timestamp\":1669926306}"
+        },
+        {
+          "name": "chakra-ui-color-mode",
+          "value": "dark"
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/packages/create-bison-app/template/tests/e2e/temp/readme.md b/packages/create-bison-app/template/tests/e2e/temp/readme.md
new file mode 100644
index 0000000..b29e8f9
--- /dev/null
+++ b/packages/create-bison-app/template/tests/e2e/temp/readme.md
@@ -0,0 +1,4 @@
+# Temp Folder
+
+Temp folder used for Playwright to storage state
+`await adminPage.context().storageState({ path: ADMIN.storageState });`
diff --git a/packages/create-bison-app/template/tests/e2e/temp/userStorageState.json b/packages/create-bison-app/template/tests/e2e/temp/userStorageState.json
new file mode 100644
index 0000000..b33b216
--- /dev/null
+++ b/packages/create-bison-app/template/tests/e2e/temp/userStorageState.json
@@ -0,0 +1,49 @@
+{
+  "cookies": [
+    {
+      "name": "next-auth.csrf-token",
+      "value": "45c9e962c8023d2ef222eb94277c2b5faed87747e4c6a59d14ae54fd3ed4edbb%7Cf4449cf28b4cd997427a976946b929a615ec14d307e3f36f7dab7f67350f4771",
+      "domain": "localhost",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": true,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "next-auth.callback-url",
+      "value": "http%3A%2F%2Flocalhost%3A3001",
+      "domain": "localhost",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": true,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "next-auth.session-token",
+      "value": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..KHRi5BhTqs0eWAl6.imFIwiI0dYBKrReS6FaU4Lhl_N3SIlyt1Yb8gso1GPvKWOzJriSkCazd3rdJxTHRq2rgKmN-1cQImYZBV6EJoAkShDKJ3VApxXNVPCpgczIysQkHWOxwasvxuW2aTdrzGMgPMWoBjCnBSh5ksbI-UNXCirPLwdZl5EjIjdiN7ujHRY3z9PanSIq-vYRxIViuCCqsyeJwkDuImlNNMCEUw9_iBsWyHQfuJAQh8h6MffNHauiOkGAZvDYBArGTY06N2UrysKDOZaDgAeMjVwZzlJQplO66V8njonksO-m64ab87ZpzCDrDQnoNZ8C1ZEIyiOkzz1rX6UqDz-dy2UZUOUVAHNfVt07DvhyAI2lm-F6wWiqjPusU-GbOONPURdPvhV2P73-hdA6pF6JnU01lnCWklSs8QbnUDOfhjxGOAWJ9mqHYGAr8ZQErMcb1SjzL7HPNzDuhk_WRjhBUpDjGMjrB57483HpyGzhvmeosxKvWopd5kA4OSj_wauCWFIo7vMWRUhhmQ_76vRAN2CAZ3ONwlCDYd8iAnvqAn7VxQ8sWuAvGbx0VfvulK1GncponOygFGrHqBm0KIVLZTT1oP2sAuA-z566VzR9ypZzE0pcxssoWr9ZKUDPtuhOcfQjRgH4YpU6GQnoglcubkT9QBF0F8Cw_ma3ieolR_DgzqMw5GMhZh9Vke3vLShF0wbYpFO39T6u-Be7nW68reic7_87JWgQE3AS4-VLgnz_gsdw2l-u4KghIW-lTwReLeb9Ik5OyEA-OuAhRzaqgt6vwravdsJZtrD6Npg4gSRCIWdMiTE2hP1RMuvOfEYb_wTVFwQjND2t5iXTbzQDIrek.iZd6IvoTZWIidCtTDn2thg",
+      "domain": "localhost",
+      "path": "/",
+      "expires": 1672518309.218937,
+      "httpOnly": true,
+      "secure": false,
+      "sameSite": "Lax"
+    }
+  ],
+  "origins": [
+    {
+      "origin": "http://localhost:3001",
+      "localStorage": [
+        {
+          "name": "nextauth.message",
+          "value": "{\"event\":\"session\",\"data\":{\"trigger\":\"getSession\"},\"timestamp\":1669926308}"
+        },
+        {
+          "name": "chakra-ui-color-mode",
+          "value": "dark"
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/packages/create-bison-app/template/tests/e2e/tsconfig.json b/packages/create-bison-app/template/tests/e2e/tsconfig.json
index 688e4a1..45d0dc7 100644
--- a/packages/create-bison-app/template/tests/e2e/tsconfig.json
+++ b/packages/create-bison-app/template/tests/e2e/tsconfig.json
@@ -1,7 +1,5 @@
 {
-  "extends": "../../cypress/tsconfig.json",
-  "compilerOptions": {
-    "isolatedModules": false
-  },
-  "include": ["../../cypress/**/*.ts", "../../cypress.config.ts", "../**/*.ts"]
-}
\ No newline at end of file
+  "extends": "../../tsconfig.json",
+  "exclude": [],
+  "include": ["**/*.d.ts", "**/*.ts", "../playwrightExamples/demo-todo-app.spec.ts"]
+}
diff --git a/packages/create-bison-app/template/tests/factories/user.ts b/packages/create-bison-app/template/tests/factories/user.ts
index 8884139..ca067b6 100644
--- a/packages/create-bison-app/template/tests/factories/user.ts
+++ b/packages/create-bison-app/template/tests/factories/user.ts
@@ -53,12 +53,13 @@ export const UserFactory = {
   upsert: async ({ where, createArgs = {}, updateArgs = {}, select = {} }: FactoryUpsertArgs) => {
     // Grab Build Defaults for Create
     const userArgs = UserFactory.build(createArgs, select);
+    const password = updateArgs.password ? hashPassword(updateArgs.password as string) : undefined;
 
     return await prisma.user.upsert({
       where,
       create: userArgs.data,
       select: userArgs.select,
-      update: updateArgs,
+      update: { ...updateArgs, password },
     });
   },
 };
diff --git a/packages/create-bison-app/test/tasks/copyFiles.test.js b/packages/create-bison-app/test/tasks/copyFiles.test.js
index acbce3a..b7ce10a 100644
--- a/packages/create-bison-app/test/tasks/copyFiles.test.js
+++ b/packages/create-bison-app/test/tasks/copyFiles.test.js
@@ -79,8 +79,8 @@ describe("copyFiles", () => {
         ".hygen.js",
         ".tool-versions",
         "constants.ts",
-        "cypress.config.ts",
         "jest.config.js",
+        "playwright.config.ts",
         "next-env.d.ts",
         "prettier.config.js",
         "README.md",
@@ -159,9 +159,13 @@ describe("copyFiles", () => {
 
     it("copies e2e tests", async () => {
       const files = [
+        "temp/",
         ".eslintrc.json",
-        "login.cy.ts",
-        "logout.cy.ts",
+        "auth.play.ts",
+        "playwrightExamples/",
+        "constants.ts",
+        "global-setup.ts",
+        "global-teardown.ts",
         "tsconfig.json",
       ];