cloud.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. import { type BrowserContext, expect, type Page } from '@playwright/test'
  2. import { settle } from './hydration'
  3. /**
  4. * Driving the app in cloud mode, as a stranger arrives at it.
  5. *
  6. * Cloud mode is where the free plan's limits bite, where the sign-up page
  7. * makes its case, and where Google sign-in exists. The specs under
  8. * `specs/cloud` run against the same build started with TORQVOICE_MODE=cloud
  9. * (see `E2E_MODE` in playwright.config.ts), and each opens a workshop of its
  10. * own rather than touching the seeded one.
  11. */
  12. const standin = process.env.E2E_GOOGLE_STANDIN ?? 'http://127.0.0.1:8027'
  13. export interface GoogleAccount {
  14. sub: string
  15. email: string
  16. email_verified: boolean
  17. name: string
  18. }
  19. /** Offers an account in the stand-in's chooser. Unverified only when asked. */
  20. export async function registerGoogleAccount(account: {
  21. email: string
  22. name?: string
  23. emailVerified?: boolean
  24. }): Promise<GoogleAccount> {
  25. const response = await fetch(`${standin}/accounts`, {
  26. method: 'POST',
  27. headers: { 'content-type': 'application/json' },
  28. body: JSON.stringify({
  29. email: account.email,
  30. name: account.name ?? account.email,
  31. email_verified: account.emailVerified ?? true,
  32. }),
  33. })
  34. if (!response.ok) {
  35. throw new Error(
  36. `the Google stand-in answered ${response.status}. Is e2e/google-standin.ts running?`
  37. )
  38. }
  39. return response.json()
  40. }
  41. export interface GoogleStandinState {
  42. accounts: GoogleAccount[]
  43. authorizeRequests: Record<string, string>[]
  44. tokenExchanges: { clientId: string; code: string; hadVerifier: boolean }[]
  45. }
  46. export async function googleStandin(): Promise<GoogleStandinState> {
  47. return (await fetch(`${standin}/state`)).json()
  48. }
  49. export async function clearGoogleStandin(): Promise<void> {
  50. await fetch(`${standin}/state`, { method: 'DELETE' })
  51. }
  52. /**
  53. * Sends the browser's trip to Google to the stand-in instead. The app builds
  54. * the real `accounts.google.com` address, as it would for a customer; only
  55. * where the browser lands changes.
  56. */
  57. export async function routeGoogleToStandin(context: BrowserContext): Promise<void> {
  58. await context.route('https://accounts.google.com/**', async (route) => {
  59. const url = new URL(route.request().url())
  60. await route.fulfill({
  61. status: 302,
  62. headers: { location: `${standin}${url.pathname}${url.search}` },
  63. })
  64. })
  65. }
  66. /** Signs up with a password on the cloud sign-up page, landing in onboarding. */
  67. export async function signUpWithPassword(
  68. page: Page,
  69. person: { name: string; email: string; password: string }
  70. ): Promise<void> {
  71. await page.goto('/auth/sign-up')
  72. await settle(page)
  73. await page.locator('#name').fill(person.name)
  74. await page.locator('#email').fill(person.email)
  75. await page.locator('#password').fill(person.password)
  76. await page.locator('#terms').click()
  77. // By type, not by name: the button's wording is the page's pitch, and a
  78. // spec that signs up in another language must still find it.
  79. await page.locator('form button[type="submit"]').click()
  80. await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
  81. }
  82. /** Names the workshop and finishes onboarding, with or without the sample data. */
  83. export async function completeOnboarding(
  84. page: Page,
  85. workshopName: string,
  86. { sampleData }: { sampleData: boolean }
  87. ): Promise<void> {
  88. await settle(page)
  89. await page.locator('#workshopName').fill(workshopName)
  90. const sample = page.locator('#loadSampleData')
  91. const checked =
  92. (await sample.getAttribute('aria-checked')) === 'true' ||
  93. (await sample.isChecked().catch(() => false))
  94. if (checked !== sampleData) await sample.click()
  95. await page.locator('form button[type="submit"]').click()
  96. await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 60_000 })
  97. }
  98. /** The dialog a plan limit opens, in place of an error. */
  99. export function upgradeDialog(page: Page) {
  100. return page.getByRole('dialog', { name: 'Upgrade to keep going' })
  101. }
  102. /** Waits for the upgrade dialog and checks it offers the way to a plan. */
  103. export async function expectUpgradeOffered(page: Page, reason: RegExp): Promise<void> {
  104. const dialog = upgradeDialog(page)
  105. await expect(dialog, 'the plan limit is offered as an upgrade').toBeVisible({ timeout: 30_000 })
  106. await expect(dialog.getByText(reason)).toBeVisible()
  107. await expect(dialog.getByRole('link')).toHaveAttribute('href', '/settings/subscription')
  108. }