cloud.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. import { type BrowserContext, expect, type Page } from '@playwright/test'
  2. import { fillSettled, 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. /**
  67. * Presses "Continue with Google" on the page that is open and picks an
  68. * account in the stand-in's chooser.
  69. */
  70. export async function chooseGoogleAccount(page: Page, email: string): Promise<void> {
  71. await settle(page)
  72. await expect(async () => {
  73. await page.getByRole('button', { name: 'Continue with Google' }).click()
  74. await expect(page.getByRole('heading', { name: 'Choose an account' })).toBeVisible({
  75. timeout: 5_000,
  76. })
  77. }).toPass({ timeout: 30_000 })
  78. await page.getByRole('button', { name: email, exact: true }).click()
  79. }
  80. /** Opens `from`, presses "Continue with Google" and picks an account in the chooser. */
  81. export async function continueWithGoogle(page: Page, from: string, email: string): Promise<void> {
  82. await page.goto(from)
  83. await chooseGoogleAccount(page, email)
  84. }
  85. /** Signs up with a password on the cloud sign-up page, landing in onboarding. */
  86. export async function signUpWithPassword(
  87. page: Page,
  88. person: { name: string; email: string; password: string }
  89. ): Promise<void> {
  90. await page.goto('/auth/sign-up')
  91. await settle(page)
  92. await page.locator('#name').fill(person.name)
  93. await page.locator('#email').fill(person.email)
  94. await page.locator('#password').fill(person.password)
  95. await page.locator('#terms').click()
  96. // By type, not by name: the button's wording is the page's pitch, and a
  97. // spec that signs up in another language must still find it.
  98. await page.locator('form button[type="submit"]').click()
  99. await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
  100. }
  101. /** Signs in with a password, from the sign-in page, and waits to be inside the app. */
  102. export async function signInWithPassword(
  103. page: Page,
  104. person: { email: string; password: string }
  105. ): Promise<void> {
  106. await page.goto('/auth/sign-in')
  107. await settle(page)
  108. await page.locator('#email').fill(person.email)
  109. await page.locator('#password').fill(person.password)
  110. await page.getByRole('button', { name: 'Sign In', exact: true }).click()
  111. await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
  112. }
  113. /**
  114. * Names the workshop and finishes onboarding on the suggested settings, with
  115. * or without the sample data: the guided setup's "skip to Ready" path.
  116. */
  117. export async function completeOnboarding(
  118. page: Page,
  119. workshopName: string,
  120. { sampleData }: { sampleData: boolean }
  121. ): Promise<void> {
  122. await settle(page)
  123. await fillSettled(page.locator('#workshopName'), workshopName)
  124. await page.getByRole('button', { name: 'Use the suggestions and skip to Ready' }).click()
  125. await expect(page.getByRole('button', { name: 'Create workshop' })).toBeVisible()
  126. const sample = page.locator('#loadSampleData')
  127. const checked =
  128. (await sample.getAttribute('aria-checked')) === 'true' ||
  129. (await sample.isChecked().catch(() => false))
  130. if (checked !== sampleData) await sample.click()
  131. await page.locator('form button[type="submit"]').click()
  132. await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 60_000 })
  133. }
  134. /** The dialog a plan limit opens, in place of an error. */
  135. export function upgradeDialog(page: Page) {
  136. return page.getByRole('dialog', { name: 'Upgrade to keep going' })
  137. }
  138. /** Waits for the upgrade dialog and checks it offers the way to a plan. */
  139. export async function expectUpgradeOffered(page: Page, reason: RegExp): Promise<void> {
  140. const dialog = upgradeDialog(page)
  141. await expect(dialog, 'the plan limit is offered as an upgrade').toBeVisible({ timeout: 30_000 })
  142. await expect(dialog.getByText(reason)).toBeVisible()
  143. await expect(dialog.getByRole('link')).toHaveAttribute('href', '/settings/subscription')
  144. }