diff --git a/package.json b/package.json index fb68d7bef..9c5f97459 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@playwright/test": "^1.59.0", "@session-foundation/mnemonic": "^0.0.9", "@session-foundation/playwright-reporter": "^0.0.8", - "@session-foundation/qa-seeder": "^0.3.0", + "@session-foundation/qa-seeder": "^0.4.1", "appium": "^3.2.2", "appium-uiautomator2-driver": "^7.1.0", "appium-xcuitest-driver": "^10.39.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13c5358e3..9cdf5af44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,8 +41,8 @@ importers: specifier: ^0.0.8 version: 0.0.8 '@session-foundation/qa-seeder': - specifier: ^0.3.0 - version: 0.3.0 + specifier: ^0.4.1 + version: 0.4.1 appium: specifier: ^3.2.2 version: 3.2.2 @@ -519,41 +519,53 @@ packages: '@session-foundation/basic-types@0.0.7': resolution: {integrity: sha512-nvNmx6uUOdDRL7S08V7NOkKL/04QOXF+FM9rq6NAl+K6lZhFn6LxWjvzvOeY9Q6kvpVX6rsyLjFDLP8k0Po5FQ==} + '@session-foundation/basic-types@0.0.8': + resolution: {integrity: sha512-O2YkvaCo1/BwH9fH740gegjDomQhbspWN3nLt1wUmt630jzJ/G98kdRYo75dQ1qId7ELL5vwZ4cagPrbRZIK7g==} + '@session-foundation/libsession-crypto@0.0.1': resolution: {integrity: sha512-VXbBKGqmfCx3VILCTIiCOD0xwRIWTfVOaKJddpPRury2ZUd0sDOw47W2sQS9JjYbI6IKlqZHZIy1H+EStvcPTA==} - '@session-foundation/libsession-wasm@0.0.12': - resolution: {integrity: sha512-1WGBlVYQ04Oj8Gg/UMPFw4aYmkCtysHD8VTVFDU/62b7lif0pk1KxjpBdGfEcB6GOcZ+nIwkWks0IRaveUCr1w==} + '@session-foundation/libsession-crypto@0.0.2': + resolution: {integrity: sha512-pm2+FJAA8gWGi7ky7y4tWAkTFGhWWhB1W7ZyVXRnns8qw+vu1UZuaEPYMgDp4m4cWVTRKLATC9Mblz2kkK0Hrw==} + + '@session-foundation/libsession-wasm@0.0.14': + resolution: {integrity: sha512-2oVxl2GY9OhfvJraIRiWLvusBg6XZZ0KMZkffBOjQfAZZfXoT7mpexx1VlIKulUxO3f7TNlSuiQi3PtvkPhkGA==} - '@session-foundation/logger@0.0.5': - resolution: {integrity: sha512-KhUALn7/9X1Qt4WifwSITAxDgvtpZTph8/isx4fSKTnvcCJxCNPm34+yHKoqDC3xA5g2uYsCC4e1egNXq73Y1A==} + '@session-foundation/logger@0.0.6': + resolution: {integrity: sha512-KoYJF1y1ni7NHXgAS58AGlD1zGjWgALdZyDZnf/sj0Jh7ZYiszTEixg1HqqLPtuiDjYbFlvVQVmFQE9b90M/Xw==} + + '@session-foundation/mnemonic@0.0.10': + resolution: {integrity: sha512-WZ5nB7gD1/1vdHyQHv/NNeHdY8XG/ezVXM6dfmpBExnvNUZS9Lf/Kl/rxze7UBciYKQQU7M7eqpHS9vGg60VGg==} '@session-foundation/mnemonic@0.0.9': resolution: {integrity: sha512-EHZ6umQ3hSkNPU8JZFKFT9rdugIthgM27ul9f3fvFnZujM7L+WsLXTfFt20ywKYWTXMZdPUXve3OU3MVbjAXCg==} - '@session-foundation/network-requests@0.0.6': - resolution: {integrity: sha512-7sreLscFO3CI1i1Woq8keUgmD2G9ZhFILidVWAULicetWggKDx7wLVG/8IQ7usDIctossJMZ8ysecMkWOb1G3A==} + '@session-foundation/network-requests@0.0.7': + resolution: {integrity: sha512-EPZZlpoWwli32/MoMjyq0ndQvgWsW8mJUPSGI9jixFuxznLRNducQuMUo96KwUcq+nS3YV3P5RfXlH8g897h0w==} - '@session-foundation/node-network-requests@0.0.5': - resolution: {integrity: sha512-oYReMBTi/FmuPMsBQDs52Ay060KmTpuy99xMdf0BGW9wKqBoWM47S5ZBTXOD9b392dG9RQJF3s2tzXngQ4/DKg==} + '@session-foundation/node-network-requests@0.0.6': + resolution: {integrity: sha512-yfpcB9Aota/7Gm0ASgiyirmtFZ0Ne87oYl0gepfZ6nyvkf6SU3dmwK9CTn5Q6WwqmoP1aXPzchM00D8zNciWBQ==} '@session-foundation/playwright-reporter@0.0.8': resolution: {integrity: sha512-VvEWcl1JgQBqTdiSxhsL/RKZziuXHa+5uginn6F2LDAnKxidNUcB6SnCfXfyr+C0+9YQqQjmkBhYtxYJMJWcWw==} - '@session-foundation/qa-seeder@0.3.0': - resolution: {integrity: sha512-acn2daJBsYEUwzNxePxuo2qiCiEIKJ3oPCxPdkmV0haNdmtMnS+6iUkiblU0l6aHYPfUPTbG6oK3mThKerxs3g==} + '@session-foundation/qa-seeder@0.4.1': + resolution: {integrity: sha512-Z4lT/5K10RtKJLUytpr+bTyy39MNBvUUoeM7xAmNz1XnYxCrZK/GLi/G2bUTyBPvzEQ9SZmlR5QN4MCjxwGsOA==} + + '@session-foundation/session-protos@0.0.2': + resolution: {integrity: sha512-1Gh5hD4NHaE9O1vRqeffLGBw0gmGijBCOwlxJ4b+iPNNEJI7TVMVbHmCQ+SujkSeGJwAQptktyvXtAoF1RZwnw==} - '@session-foundation/snode-rpc@0.0.1': - resolution: {integrity: sha512-Ie5wQe3fKRDd0isGsli1KEhbrvs4sSXqrG6DUNiI6PAgt2r/WbLUrVcLpb28KMsre6Cu4cSusDP733vhlt545A==} + '@session-foundation/snode-rpc@0.0.2': + resolution: {integrity: sha512-zviXs2Tqk61E4JoC6jGoH7xlIdnBp9DatirEpBbKv1Lp3sbfh9ervOmV8k473MmeYwgHJRyd0h9vUfnG62AkJw==} - '@session-foundation/swarm-network@0.0.1': - resolution: {integrity: sha512-HcBPAyCOMyP7bYhkxwbw3FjcfxycD0xotD/T2BaKib+UxbUu8YUtiEGvAniwXlbkjTPx6zJ+lKiRKX/1rXuZAg==} + '@session-foundation/swarm-network@0.0.2': + resolution: {integrity: sha512-1IOrg7r5MDnqGn6dvcy8jQR6nQQW9Vd7Jy5UfEK5Fj3+bIHcFKUiZpzJiSQyvz7CJ61MbaPj4Zy77CeFtLgtMA==} - '@session-foundation/swarm-routing@0.0.1': - resolution: {integrity: sha512-9rMY6K13+05ZvoBNY+f+gbEpDgKQMlhTbpJZqfdWphKGDu+0BFLQkXhIt1PI3ogbCEQyCpksubOs4E7meZy+Kw==} + '@session-foundation/swarm-routing@0.0.2': + resolution: {integrity: sha512-fCwLE5n2lizXt/yYi6uF3EGB9iJlCi0Feim8Agtm7UBWRUlMqIV1pxSYgTbVo+fBy2/Sy3HeJdleqHwP1orWtA==} - '@session-foundation/wasm-libsession-crypto@0.0.2': - resolution: {integrity: sha512-YWULILcuSBEKs5CSljOTzNX0pLyw8/mE8CbLOQjQsCgHwTydiCKiWE/uc3zP4OGwng7zVHfX0jQlLlZZndqySA==} + '@session-foundation/wasm-libsession-crypto@0.0.3': + resolution: {integrity: sha512-/VOw+ysYmWmxKfi2mAaxe9v2uf5TxTj3SKaRocmjoIetv8Q0B2/U2N5Lwlx7QJ6wMlOREL2aj0aJ4MQjDALEgA==} '@sidvind/better-ajv-errors@4.0.1': resolution: {integrity: sha512-6arF1ssKxItxgitPYXafUoLmsVBA6K7m9+ZGj6hLDoBl7nWpJ33EInwQUdHTle2METeWGxgQiqSex20KZRykew==} @@ -2150,6 +2162,9 @@ packages: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + looks-same@10.0.1: resolution: {integrity: sha512-MHTGwSbifNjQIKkjAr9aOCDxg+E06wtdLBdSmjJSnFxl7u5rkQqTfx+FYK+Fv6sK2MKJFlBaTYN6WOLdm5JSGA==} engines: {node: '>= 18.0.0'} @@ -2584,6 +2599,10 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + protobufjs@8.8.0: + resolution: {integrity: sha512-N3xhQ5yyBx3vQq4gubBfASzYhJGNzeDbjqBpu61g7UVylsN/qyffU96TKWD3GbbLOKF82VGNRNvv1+BFgE31Eg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -3752,33 +3771,47 @@ snapshots: buffer-crc32: 1.0.0 zod: 3.25.76 + '@session-foundation/basic-types@0.0.8': + dependencies: + buffer-crc32: 1.0.0 + zod: 3.25.76 + '@session-foundation/libsession-crypto@0.0.1': dependencies: '@session-foundation/basic-types': 0.0.7 - '@session-foundation/libsession-wasm@0.0.12': {} + '@session-foundation/libsession-crypto@0.0.2': + dependencies: + '@session-foundation/basic-types': 0.0.8 + + '@session-foundation/libsession-wasm@0.0.14': {} + + '@session-foundation/logger@0.0.6': {} - '@session-foundation/logger@0.0.5': {} + '@session-foundation/mnemonic@0.0.10': + dependencies: + '@session-foundation/basic-types': 0.0.8 + '@session-foundation/libsession-crypto': 0.0.2 '@session-foundation/mnemonic@0.0.9': dependencies: '@session-foundation/basic-types': 0.0.7 '@session-foundation/libsession-crypto': 0.0.1 - '@session-foundation/network-requests@0.0.6': + '@session-foundation/network-requests@0.0.7': dependencies: - '@session-foundation/basic-types': 0.0.7 - '@session-foundation/libsession-crypto': 0.0.1 - '@session-foundation/logger': 0.0.5 + '@session-foundation/basic-types': 0.0.8 + '@session-foundation/libsession-crypto': 0.0.2 + '@session-foundation/logger': 0.0.6 lodash: 4.17.23 zod: 3.25.76 - '@session-foundation/node-network-requests@0.0.5': + '@session-foundation/node-network-requests@0.0.6': dependencies: - '@session-foundation/basic-types': 0.0.7 - '@session-foundation/logger': 0.0.5 - '@session-foundation/network-requests': 0.0.6 - '@session-foundation/snode-rpc': 0.0.1 + '@session-foundation/basic-types': 0.0.8 + '@session-foundation/logger': 0.0.6 + '@session-foundation/network-requests': 0.0.7 + '@session-foundation/snode-rpc': 0.0.2 lodash: 4.17.23 node-fetch: 3.3.2 zod: 3.25.76 @@ -3787,40 +3820,52 @@ snapshots: dependencies: lodash: 4.17.23 - '@session-foundation/qa-seeder@0.3.0': + '@session-foundation/qa-seeder@0.4.1': + dependencies: + '@session-foundation/basic-types': 0.0.8 + '@session-foundation/libsession-crypto': 0.0.2 + '@session-foundation/libsession-wasm': 0.0.14 + '@session-foundation/mnemonic': 0.0.10 + '@session-foundation/network-requests': 0.0.7 + '@session-foundation/node-network-requests': 0.0.6 + '@session-foundation/session-protos': 0.0.2 + '@session-foundation/swarm-network': 0.0.2 + '@session-foundation/wasm-libsession-crypto': 0.0.3 + lodash: 4.17.23 + long: 5.3.2 + node-fetch: 3.3.2 + + '@session-foundation/session-protos@0.0.2': dependencies: - '@session-foundation/basic-types': 0.0.7 - '@session-foundation/libsession-crypto': 0.0.1 - '@session-foundation/libsession-wasm': 0.0.12 - '@session-foundation/mnemonic': 0.0.9 - '@session-foundation/network-requests': 0.0.6 - '@session-foundation/node-network-requests': 0.0.5 - '@session-foundation/swarm-network': 0.0.1 - '@session-foundation/wasm-libsession-crypto': 0.0.2 + '@session-foundation/basic-types': 0.0.8 + '@session-foundation/logger': 0.0.6 + '@session-foundation/network-requests': 0.0.7 lodash: 4.17.23 node-fetch: 3.3.2 + protobufjs: 8.8.0 + zod: 3.25.76 - '@session-foundation/snode-rpc@0.0.1': + '@session-foundation/snode-rpc@0.0.2': dependencies: - '@session-foundation/basic-types': 0.0.7 - '@session-foundation/network-requests': 0.0.6 + '@session-foundation/basic-types': 0.0.8 + '@session-foundation/network-requests': 0.0.7 zod: 3.25.76 - '@session-foundation/swarm-network@0.0.1': + '@session-foundation/swarm-network@0.0.2': dependencies: - '@session-foundation/basic-types': 0.0.7 - '@session-foundation/swarm-routing': 0.0.1 + '@session-foundation/basic-types': 0.0.8 + '@session-foundation/swarm-routing': 0.0.2 - '@session-foundation/swarm-routing@0.0.1': + '@session-foundation/swarm-routing@0.0.2': dependencies: - '@session-foundation/basic-types': 0.0.7 - '@session-foundation/network-requests': 0.0.6 + '@session-foundation/basic-types': 0.0.8 + '@session-foundation/network-requests': 0.0.7 lodash: 4.17.23 - '@session-foundation/wasm-libsession-crypto@0.0.2': + '@session-foundation/wasm-libsession-crypto@0.0.3': dependencies: - '@session-foundation/libsession-crypto': 0.0.1 - '@session-foundation/libsession-wasm': 0.0.12 + '@session-foundation/libsession-crypto': 0.0.2 + '@session-foundation/libsession-wasm': 0.0.14 '@sidvind/better-ajv-errors@4.0.1(ajv@8.18.0)': dependencies: @@ -5744,6 +5789,8 @@ snapshots: loglevel@1.9.2: {} + long@5.3.2: {} + looks-same@10.0.1: dependencies: '@jsquash/png': 3.1.1 @@ -6147,6 +6194,10 @@ snapshots: progress@2.0.3: {} + protobufjs@8.8.0: + dependencies: + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 diff --git a/run/desktop/seeded_state.ts b/run/desktop/seeded_state.ts index 57ce38601..b5abec0fa 100644 --- a/run/desktop/seeded_state.ts +++ b/run/desktop/seeded_state.ts @@ -17,6 +17,7 @@ import type { Page } from '@playwright/test'; import { buildStateForTest, + type BuildStateOptions, type PrebuiltStateKey, type StateGroup, type StateUser, @@ -55,12 +56,15 @@ export async function openSeededWindows({ windowsPerUser, extraWindows = 0, context, + stateOptions, }: { stateKey: K; groupName: K extends WithGroupStateKey ? string : undefined; windowsPerUser: number[]; extraWindows?: number; context?: TestContext; + /// Seeded Pro access and pins, addressed by index into the state's user list. + stateOptions?: BuildStateOptions; }): Promise<{ users: SeededUser[]; /** Windows with no account on them, in the order requested. */ @@ -83,7 +87,7 @@ export async function openSeededWindows({ openApps(totalWindows, context).then(apps => Promise.all(apps.map(app => waitFirstWindow(app))) ), - buildStateForTest(stateKey, groupName, network), + buildStateForTest(stateKey, groupName, network, stateOptions), ]); const seedUsers = (prebuilt as { users: StateUser[] }).users; diff --git a/run/desktop/sessionTest.ts b/run/desktop/sessionTest.ts index f79e0ea0f..91aa87b87 100644 --- a/run/desktop/sessionTest.ts +++ b/run/desktop/sessionTest.ts @@ -481,10 +481,13 @@ function sessionTestSeededProAccess( testName, async (pages, testInfo) => { const opened = await openSeededWindows({ - stateKey: '1userWithProAccess', + stateKey: '1user', groupName: undefined, windowsPerUser: [1], context, + // Was its own state key until qa-seeder 0.4, which made Pro an option that composes with any + // state rather than a key of its own. + stateOptions: { pro: { 0: {} } }, }); pages.push(...opened.pages); await testCallback( diff --git a/run/test/specs/mobile/pro_seeded_access_grant.spec.ts b/run/test/specs/mobile/pro_seeded_access_grant.spec.ts new file mode 100644 index 000000000..ab7abb6c8 --- /dev/null +++ b/run/test/specs/mobile/pro_seeded_access_grant.spec.ts @@ -0,0 +1,77 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { TestSteps } from '../../../types/allure'; +import { bothPlatformsIt } from '../../../types/sessionIt'; +import { ProSettingsEntry, ProStatsHeader } from '../../locators/pro'; +import { UserSettings } from '../../locators/settings'; +import { ALICE_IS_PRO, open_Alice1 } from '../../state_builder'; +import { closeApp, SupportedPlatformsType } from '../../utils/open_app'; +import { PRO_BACKEND_CONTEXT } from '../../utils/pro_context'; +import { assertProFromSettingsRow } from '../../utils/pro_refresh'; + +bothPlatformsIt({ + title: 'Pro is granted to a seeded account', + risk: 'high', + countOfDevicesNeeded: 1, + testCb: proGrantedToSeededAccount, + isPro: true, + allureSuites: { parent: 'Session Pro' }, + allureDescription: + 'An account seeded with Pro access, granted a real entitlement before it is ever restored, is Pro ' + + 'as soon as the restore completes — with nothing prompting the client to look.', +}); + +/** + * The mobile counterpart of desktop's `pro_seeded_access_grant`, and the one spec that states the + * cold-launch gate works. + * + * Every other real-grant spec calls `observeProGrant`, which restarts the client and walks it into Pro + * settings — a screen that fetches unconditionally. That is a deliberate workaround, so none of them + * can say whether the client would ever have noticed on its own. This one can, because + * `open_Alice1_pro` seeds the access expiry the gate reads and mints the entitlement *before* the + * restore. + * + * The assertion order is the whole design: + * + * 1. The Pro row on the **parent** settings list, which no client refreshes on opening. Reading it + * observes what the client already believed, so passing here means the gated startup fetch ran and + * resolved with no help from the test. + * 2. Only then the stats section, which renders for an ACTIVE PLAN — so it separates a client that + * fetched a status from one holding a proof the backend really signed. + * + * Opening Pro settings for (2) provokes a fetch, so it could stand alone only as "the client can be + * made Pro". (1) is what makes it "the client became Pro by itself", and it has to come first. + */ +async function proGrantedToSeededAccount(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + return await open_Alice1({ + platform, + testInfo, + testContext: PRO_BACKEND_CONTEXT, + stateOptions: ALICE_IS_PRO, + }); + }); + + await test.step('Verify the client already believes it is Pro', async () => { + await device.clickOnElementAll(new UserSettings(device)); + // Reads and leaves — see `assertProFromSettingsRow`. Deliberately not `observeProGrant`: that + // would restart and open Pro settings, which is exactly the prompting this spec exists to avoid. + await assertProFromSettingsRow(device); + }); + + await test.step('Verify the entitlement is real', async () => { + await device.clickOnElementAll(new UserSettings(device)); + await device.clickOnElementAll(new ProSettingsEntry(device)); + // `skipHealing` for the reason `observeProGrant` gives: healing's fuzzy id match resolves to a + // neighbouring `pro-settings-*` element on the non-Pro screen, so the wait would pass while the + // account was NeverSubscribed. + await device.waitForTextElementToBePresent({ + ...new ProStatsHeader(device).build(), + skipHealing: true, + }); + }); + + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} diff --git a/run/test/state_builder/index.ts b/run/test/state_builder/index.ts index 34c1a2553..74f6f49be 100644 --- a/run/test/state_builder/index.ts +++ b/run/test/state_builder/index.ts @@ -8,6 +8,7 @@ import type { TestInfo } from '@playwright/test'; import { buildStateForTest, + type BuildStateOptions, type PrebuiltStateKey, type StateUser, type WithGroupStateKey, @@ -15,6 +16,7 @@ import { import type { DeviceWrapper } from '../../types/DeviceWrapper'; +import { makeAccountPro } from '../../shared/pro_grant'; import { ConversationItem } from '../locators/home'; import { resolveNetworkTarget } from '../utils/devnet'; import { openAppMultipleDevices, type SupportedPlatformsType } from '../utils/open_app'; @@ -29,6 +31,14 @@ type WithDracula = { dracula: StateUser }; type WithFocusFriendsConvo = { focusFriendsConvo: boolean }; type WithFocusGroupConvo = { focusGroupConvo: boolean }; type WithPlatform = { platform: SupportedPlatformsType }; +type WithTestInfo = { testInfo: TestInfo }; +type WithTestContext = { testContext?: MobileTestContext }; +/// The per-device form, for builders that open more than one app - a spec whose devices must DISAGREE +/// passes an array. See `MobileTestContexts`. +type WithTestContexts = { testContext?: MobileTestContexts }; +/// Seeded Pro access and pins, addressed by index into the state's user list. Composes with every +/// state, which is why a Pro fixture needs no state key of its own. +type WithStateOptions = { stateOptions?: BuildStateOptions }; export type AppCountPerTest = 1 | 2 | 3 | 4; @@ -93,13 +103,14 @@ async function openAppsWithState m.seedPhrase); + await grantProToSeededUsers(result.prebuilt.users, platform, stateOptions?.pro); await linkDevices(result.devices, seedPhrases); const alice = result.prebuilt.users[0]; @@ -212,9 +228,8 @@ export async function open_Alice1_Bob1_Charlie1_friends_group({ }: WithPlatform & WithFocusGroupConvo & { groupName: string; - testInfo: TestInfo; - testContext?: MobileTestContext; - }) { + } & WithTestInfo & + WithTestContext) { const stateToBuildKey = '3friendsInGroup'; const appsToOpen = 3; const result = await openAppsWithState({ @@ -273,9 +288,8 @@ export async function open_Alice2_Bob1_Charlie1_friends_group({ }: WithPlatform & WithFocusGroupConvo & { groupName: string; - testInfo: TestInfo; - testContext?: MobileTestContext; - }) { + } & WithTestInfo & + WithTestContext) { const stateToBuildKey = '3friendsInGroup'; const appsToOpen = 4; const result = await openAppsWithState({ @@ -339,9 +353,8 @@ export async function open_Alice1_Bob1_Charlie1_Unknown1({ }: WithPlatform & WithFocusGroupConvo & { groupName: string; - testInfo: TestInfo; - testContext?: MobileTestContext; - }) { + } & WithTestInfo & + WithTestContext) { const stateToBuildKey = '3friendsInGroup'; const appsToOpen = 4; const result = await openAppsWithState({ @@ -406,7 +419,8 @@ export async function open_Alice1({ platform, testInfo, testContext, -}: WithPlatform & { testInfo: TestInfo; testContext?: MobileTestContext }) { + stateOptions, +}: WithPlatform & {} & WithTestInfo & WithTestContext & WithStateOptions) { const result = await openAppsWithState({ platform, appsToOpen: 1, @@ -414,19 +428,62 @@ export async function open_Alice1({ groupName: undefined, testInfo, testContext, + stateOptions, }); result.devices[0].setDeviceIdentity('alice1'); const alice = result.prebuilt.users[0]; + await grantProToSeededUsers(result.prebuilt.users, platform, stateOptions?.pro); await linkDevices([result.devices[0]], [alice.seedPhrase]); return { device: result.devices[0], alice }; } +/** + * Make the named seeded users Pro before any device has seen the account. + * + * Two halves that each need the other. The seeded state writes an access expiry into config + * (`stateOptions.pro`); this mints the entitlement the backend signs. A grant with no seeded expiry + * stays invisible, because the cold-launch gate reads the expiry FROM CONFIG to decide whether to ask + * `get_pro_status` at all — and a seeded expiry is not itself an entitlement, since no config write + * can forge a proof. + * + * **Both have to land before `linkDevices`.** The restore is the client's first sight of the account, + * so the gated fetch fires as it completes; a grant arriving after that answers `never`, and both + * clients then floor the next attempt at 60s from that ATTEMPT. Getting the order right is what lets a + * spec skip `observeProGrant` — the client learns it is Pro on its own rather than being walked into + * Pro settings to provoke a fetch. + * + * Still `makeAccountPro` rather than the seeder's own `fetchProProof`: that one verifies the proof + * against the key the instance actually signs with, which is worth having, but this keeps the guards + * against granting to the shared moderation account and against a phrase that does not derive the + * account under test. + */ +async function grantProToSeededUsers( + users: Array, + platform: SupportedPlatformsType, + pro: BuildStateOptions['pro'] +) { + await Promise.all( + Object.keys(pro ?? {}).map(index => { + const user = users[Number(index)]; + if (!user) { + throw new Error( + `grantProToSeededUsers: pro options name user ${index} but the state has ${users.length}` + ); + } + return makeAccountPro({ user, platform }); + }) + ); +} + +/** Seed Alice's Pro access, which is what makes the client's own startup fetch eligible. */ +export const ALICE_IS_PRO: BuildStateOptions = { pro: { 0: {} } }; + export async function open_Alice2({ platform, testInfo, testContext, -}: WithPlatform & { testInfo: TestInfo; testContext?: MobileTestContext }) { +}: WithPlatform & WithTestInfo & WithTestContext) { const prebuiltStateKey = '1user'; const appsToOpen = 2; const result = await openAppsWithState({ @@ -468,7 +525,7 @@ export async function open_Alice1_bob1_notfriends({ platform, testInfo, testContext, -}: WithPlatform & { testInfo: TestInfo; testContext?: MobileTestContext }) { +}: WithPlatform & WithTestInfo & WithTestContext) { const appsToOpen = 2; const result = await openAppsWithState({ platform, @@ -508,8 +565,7 @@ export async function open_Alice2_Bob1_friends({ focusFriendsConvo, testInfo, testContext, -}: WithPlatform & - WithFocusFriendsConvo & { testInfo: TestInfo; testContext?: MobileTestContexts }) { +}: WithPlatform & WithFocusFriendsConvo & WithTestInfo & WithTestContexts) { const prebuiltStateKey = '2friends'; const appsToOpen = 3; const result = await openAppsWithState({