playwright.config.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import { createPrivateKey, sign } from 'node:crypto'
  2. import { resolve } from 'node:path'
  3. import { defineConfig, devices } from '@playwright/test'
  4. /**
  5. * End-to-end tests: the app in a real browser, against a real Postgres.
  6. *
  7. * The 190-odd vitest files mock Prisma, so they prove the actions think
  8. * correctly and nothing else. These prove the parts that only break when the
  9. * pieces are assembled: migrations, the session cookie, server actions wired to
  10. * forms, and the invoice numbering that customers actually see.
  11. *
  12. * Deliberately serial against one seeded database. Parallel workers sharing a
  13. * quote counter would fail on each other's numbers rather than on real bugs.
  14. */
  15. const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100'
  16. const port = new URL(baseURL).port || '3100'
  17. /** The database the harness is allowed to destroy. Never the dev one. */
  18. const databaseUrl = process.env.E2E_DATABASE_URL ?? ''
  19. /** Where the mail sink listens: SMTP for the app, HTTP for the specs. */
  20. const smtpPort = process.env.E2E_SMTP_PORT ?? '1025'
  21. const mailApiPort = process.env.E2E_MAIL_API_PORT ?? '8025'
  22. /**
  23. * A mail server that delivers nothing, so the specs can read what the app
  24. * posted. Started whether or not the suite starts the app: pointed at a
  25. * server somebody else launched, that server is told to send here too.
  26. */
  27. /** Where the stand-in payment vendor listens, for the app and for the specs. */
  28. const paymentPort = process.env.E2E_PAYMENT_PORT ?? '8026'
  29. const paymentSinkUrl = `http://127.0.0.1:${paymentPort}`
  30. /**
  31. * Stripe and PayPal as far as the app can tell, with a checkout page a spec
  32. * can pay on. Started in every mode, like the mail sink: a server somebody
  33. * else launched is told to use it through the two base URLs below.
  34. */
  35. const paymentSink = {
  36. command: 'npx tsx e2e/payment-sink.ts',
  37. url: `${paymentSinkUrl}/health`,
  38. reuseExistingServer: !process.env.CI,
  39. timeout: 60_000,
  40. stdout: 'pipe' as const,
  41. stderr: 'pipe' as const,
  42. env: { E2E_PAYMENT_PORT: paymentPort },
  43. }
  44. /**
  45. * Which app the run is against. The suite's specs expect a self-hosted
  46. * install, where every feature is unlocked. `E2E_MODE=cloud` runs only
  47. * `specs/cloud`, against the same build started in cloud mode: plan limits,
  48. * the sign-up pitch and Google sign-in exist only there. One build serves
  49. * both, because the app URL baked into it is the same.
  50. */
  51. const cloud = process.env.E2E_MODE === 'cloud'
  52. /**
  53. * Cloud mode needs a token torqvoice.com signed for the app's URL, or the app
  54. * ignores TORQVOICE_MODE and runs self-hosted (see src/lib/cloud-instance.ts).
  55. * The run mints its own from the real signing key, in TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY
  56. * (a repository secret in CI), bound to the base URL and dead after a day, so
  57. * a copy that leaks out of a run is worth nothing for long. There is no test
  58. * key the app would accept instead: that would be a second way in.
  59. */
  60. function mintCloudToken(): string {
  61. const raw = process.env.TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY?.trim()
  62. if (!raw) {
  63. throw new Error(
  64. 'E2E_MODE=cloud needs TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY (the torqvoice.com licence signing key) to mint a cloud token for the run.'
  65. )
  66. }
  67. const key = createPrivateKey({ key: Buffer.from(raw, 'base64'), format: 'der', type: 'pkcs8' })
  68. const now = Date.now()
  69. const payload = {
  70. v: 1,
  71. origin: new URL(baseURL).origin,
  72. issuedAt: new Date(now).toISOString(),
  73. expiresAt: new Date(now + 24 * 60 * 60 * 1000).toISOString(),
  74. }
  75. const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
  76. return `tvc1.${encoded}.${sign(null, Buffer.from(encoded), key).toString('base64url')}`
  77. }
  78. /** Where the Google stand-in listens, for the app's server and for the specs. */
  79. const googlePort = process.env.E2E_GOOGLE_PORT ?? '8027'
  80. const googleStandinUrl = `http://127.0.0.1:${googlePort}`
  81. /**
  82. * Google's account chooser and token endpoint, for the cloud run. The browser
  83. * is routed to it by the specs; the app's server is pointed at it by a
  84. * preload, because better-auth has Google's endpoints written into it.
  85. */
  86. const googleStandin = {
  87. command: 'npx tsx e2e/google-standin.ts',
  88. url: `${googleStandinUrl}/health`,
  89. reuseExistingServer: !process.env.CI,
  90. timeout: 60_000,
  91. stdout: 'pipe' as const,
  92. stderr: 'pipe' as const,
  93. env: { E2E_GOOGLE_PORT: googlePort },
  94. }
  95. /** Where the torqvoice.com stand-in listens, for the app's server and for the specs. */
  96. const torqvoiceComPort = process.env.E2E_TORQVOICE_COM_PORT ?? '8028'
  97. const torqvoiceComUrl = `http://127.0.0.1:${torqvoiceComPort}`
  98. /** Shared with the app as TORQVOICE_SERVICE_SECRET; throwaway, like the auth secret. */
  99. const serviceSecret = 'e2e-service-secret-0123456789abcdef'
  100. /**
  101. * torqvoice.com for the cloud run: the bearer API the app calls for the
  102. * billing portal, cancel, resume and upgrade, the checkout page a purchase
  103. * hands the browser to, and the account link. Tokens are verified there
  104. * the way the real site verifies them.
  105. */
  106. const torqvoiceComStandin = {
  107. command: 'npx tsx e2e/torqvoice-com-standin.ts',
  108. url: `${torqvoiceComUrl}/health`,
  109. reuseExistingServer: !process.env.CI,
  110. timeout: 60_000,
  111. stdout: 'pipe' as const,
  112. stderr: 'pipe' as const,
  113. env: { E2E_TORQVOICE_COM_PORT: torqvoiceComPort, E2E_SERVICE_SECRET: serviceSecret },
  114. }
  115. const mailSink = {
  116. command: 'npx tsx e2e/mail-sink.ts',
  117. url: `http://127.0.0.1:${mailApiPort}/health`,
  118. reuseExistingServer: !process.env.CI,
  119. timeout: 60_000,
  120. stdout: 'pipe' as const,
  121. stderr: 'pipe' as const,
  122. env: { E2E_SMTP_PORT: smtpPort, E2E_MAIL_API_PORT: mailApiPort },
  123. }
  124. export default defineConfig({
  125. testDir: './e2e',
  126. globalSetup: './e2e/global-setup.ts',
  127. timeout: 60_000,
  128. expect: { timeout: 10_000 },
  129. fullyParallel: false,
  130. workers: 1,
  131. forbidOnly: !!process.env.CI,
  132. retries: process.env.CI ? 1 : 0,
  133. // On CI the suite runs in shards, one job each, and every shard writes a
  134. // blob; the workflow's last job merges them into a single HTML report.
  135. reporter: process.env.CI ? [['github'], ['blob']] : [['list'], ['html', { open: 'never' }]],
  136. use: {
  137. baseURL,
  138. // Pinned, because selectors read visible text and the app speaks twelve
  139. // languages. The locale cookie is set alongside this in auth.setup.ts.
  140. locale: 'en-US',
  141. extraHTTPHeaders: { 'accept-language': 'en' },
  142. // Pinned for the same reason invoice dates are: a floating timezone turns
  143. // a date assertion into a coin toss either side of midnight.
  144. timezoneId: process.env.E2E_TZ ?? 'Europe/Oslo',
  145. trace: 'retain-on-failure',
  146. screenshot: 'only-on-failure',
  147. video: 'retain-on-failure',
  148. },
  149. projects: [
  150. { name: 'setup', testMatch: /auth\.setup\.ts/ },
  151. {
  152. name: 'chromium',
  153. use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/owner.json' },
  154. dependencies: ['setup'],
  155. // The cloud specs need the app in cloud mode, and the rest need it
  156. // self-hosted, so each run takes only its own.
  157. ...(cloud ? { testMatch: /specs\/cloud\/.*\.spec\.ts$/ } : { testIgnore: /specs\/cloud\// }),
  158. },
  159. ],
  160. /**
  161. * The mail sink always; the app only when E2E_BASE_URL is unset, so pointing
  162. * the suite at a running container (or a staging host) is a matter of
  163. * setting one variable.
  164. *
  165. * `next start` and not `next dev`: the dev server compiles routes on first
  166. * visit, which turns the first assertion in every spec into a timeout race,
  167. * and it is not the artifact that ships anyway.
  168. */
  169. webServer: process.env.E2E_BASE_URL
  170. ? [mailSink, paymentSink, ...(cloud ? [googleStandin, torqvoiceComStandin] : [])]
  171. : [
  172. mailSink,
  173. paymentSink,
  174. ...(cloud ? [googleStandin, torqvoiceComStandin] : []),
  175. {
  176. // The database first, then the server, in one command: Playwright
  177. // starts this before global setup, and a server on an empty schema
  178. // fails the readiness check on every page.
  179. command: `npx tsx e2e/prepare-db.ts && npm run start -- --port ${port}`,
  180. url: baseURL,
  181. reuseExistingServer: !process.env.CI,
  182. // Sixty-odd migrations, the seed and its vehicle photos, then the
  183. // server: a cold CI runner needs longer than a warm laptop.
  184. timeout: process.env.CI ? 420_000 : 180_000,
  185. stdout: 'pipe',
  186. stderr: 'pipe',
  187. env: {
  188. E2E_DATABASE_URL: databaseUrl,
  189. DATABASE_URL: databaseUrl,
  190. NEXT_PUBLIC_APP_URL: baseURL,
  191. // Long and random enough that better-auth does not spend the run
  192. // warning about it. Throwaway: it signs sessions for a database
  193. // the harness resets, and CI generates its own per run.
  194. BETTER_AUTH_SECRET:
  195. process.env.BETTER_AUTH_SECRET ?? 'k3Qb8vZ1hN7pXtR2yJm5Ls9CwD4gFa6UeH0iOoT+PbY=',
  196. // The schedulers would otherwise tick through the run, writing to
  197. // the rows the specs are asserting on.
  198. DISABLE_BACKGROUND_JOBS: '1',
  199. // Demo mode blocks invites, billing and outbound messages. Tests want
  200. // the real behaviour, so it stays off.
  201. DEMO_MODE: 'false',
  202. // Three sign-ins per ten seconds is right for a workshop and wrong
  203. // for a suite that signs in on every test.
  204. AUTH_RATE_LIMIT: 'off',
  205. // `next start` also reads the developer's .env, which may say cloud.
  206. // Self-hosted unlocks every feature, which is what a suite that
  207. // exercises them needs; plan gates are a subject of their own.
  208. TORQVOICE_MODE: cloud ? 'cloud' : 'self-hosted',
  209. // Google sign-in exists only in cloud mode, and only with a client
  210. // configured. The preload sends the server's token exchange to the
  211. // stand-in; outside the cloud run none of this is set.
  212. ...(cloud
  213. ? {
  214. GOOGLE_AUTH_CLIENT_ID: 'e2e-google-client',
  215. GOOGLE_AUTH_CLIENT_SECRET: 'e2e-google-secret',
  216. E2E_GOOGLE_STANDIN_URL: googleStandinUrl,
  217. NODE_OPTIONS: `--import=${resolve('e2e/google-standin-preload.mjs')}`,
  218. TORQVOICE_CLOUD_TOKEN: mintCloudToken(),
  219. // Plans are sold on torqvoice.com; the stand-in plays it.
  220. // The link check forgets its answer after a second instead of
  221. // an hour or two minutes, so a spec can flip the stand-in's answer.
  222. TORQVOICE_SERVICE_SECRET: serviceSecret,
  223. // The server variable, not NEXT_PUBLIC_: that one is baked
  224. // into the bundle at build time and would point at the real site.
  225. TORQVOICE_COM_URL: torqvoiceComUrl,
  226. TORQVOICE_COM_LINK_TTL_SECONDS: '1',
  227. TORQVOICE_COM_LINK_RETRY_SECONDS: '1',
  228. }
  229. : {}),
  230. TZ: process.env.E2E_TZ ?? 'Europe/Oslo',
  231. // Mail goes to the sink instead of a provider. The app's SMTP
  232. // settings fall back to these when nothing is configured in the
  233. // database, which is how the seeded workshop is left.
  234. SMTP_HOST: '127.0.0.1',
  235. SMTP_PORT: smtpPort,
  236. SMTP_FROM_EMAIL: 'workshop@e2e.test',
  237. SMTP_SECURE: 'false',
  238. // Payments go to the stand-in vendor. Read only from the
  239. // environment, so nothing a workshop stores can redirect a key.
  240. STRIPE_API_BASE_URL: paymentSinkUrl,
  241. PAYPAL_API_BASE_URL: paymentSinkUrl,
  242. },
  243. },
  244. ],
  245. })