diff --git a/scripts/ruby/rename.rb b/scripts/ruby/rename.rb index a8581c8..bafbe07 100755 --- a/scripts/ruby/rename.rb +++ b/scripts/ruby/rename.rb @@ -29,7 +29,12 @@ stats = { files_scanned: 0, files_changed: 0, substitutions: 0, files_renamed: 0 } SKIP_DIR_SEGMENTS = %w[.git node_modules tmp log DerivedData Pods Carthage xcuserdata .build build .gradle .idea .kotlin captures].freeze -SKIP_SUBPATHS = %w[vendor/bundle app/assets/builds public/assets].freeze +# The Solid Queue / Cache / Cable schemas and migrations are the gems' own: +# their columns (completed_jobs) must stay what the gems expect, whatever the +# plan renames. Mirrored in src/validation/layer1.ts. +SKIP_SUBPATHS = %w[vendor/bundle app/assets/builds public/assets + db/queue_schema.rb db/cache_schema.rb db/cable_schema.rb + db/queue_migrate db/cache_migrate db/cable_migrate].freeze TEXT_EXTS = %w[ .rb .erb .yml .yaml .json .md .gemspec .rake .ru .txt .sample .example .conf .html .css .scss .js .mjs .tt .lock @@ -244,12 +249,17 @@ def rename_all(text) # substitution and restored after it, so nothing inside them is renamed: not # the product name, nor a Shop / ItemTag token in a URL path. The product's # domains and emails (nativeapptemplate.com) are not protected; they become -# the app's own. Mirrored in src/validation/protected-references.ts, which -# keeps Layer 1 from flagging what is left here. +# the app's own. The RevenueCat SDK's PurchasesAreCompletedBy (Android) is +# protected the same way: a plan that renames the Completed state would +# otherwise turn it into an import that doesn't exist. Mirrored in +# src/validation/protected-references.ts, which keeps Layer 1 from flagging +# what is left here. PROTECTED_REFERENCE = %r{ github\.com[/:]nativeapptemplate(?![A-Za-z0-9_-])(?:/[^\s)\]"'<>`]*)? | (? { for (const entry of entries) { if (SKIP_SEGMENTS.has(entry.name)) continue; const full = join(dir, entry.name); + const rel = relative(root, full).split(sep).join("/"); + if (SKIP_SUBPATHS.includes(rel)) continue; if (entry.isDirectory()) { - const rel = relative(root, full).split(sep).join("/"); - if (SKIP_SUBPATHS.includes(rel)) continue; yield* walk(full, root); } else if (entry.isFile() && isTextFile(full)) { yield full; diff --git a/src/validation/protected-references.ts b/src/validation/protected-references.ts index 69ef5b7..9b8baf3 100644 --- a/src/validation/protected-references.ts +++ b/src/validation/protected-references.ts @@ -4,14 +4,16 @@ // github.com/orangebaton/OrangeBaton-iOS). Mirrors PROTECTED_REFERENCE in // scripts/ruby/rename.rb; Layer 1 skips tokens inside these spans. // nativeapptemplate.com domains and emails are deliberately not here: they are -// renamed to the app's own. +// renamed to the app's own. The RevenueCat SDK's PurchasesAreCompletedBy is +// protected too, against a plan that renames the Completed state. const PROTECTED_REFERENCE_SOURCE = [ String.raw`github\.com[/:]nativeapptemplate(?![A-Za-z0-9_-])(?:/[^\s)\]"'<>\x60]*)?`, String.raw`(? { - if (!line.includes("nativeapptemplate")) return []; + if (!line.includes("nativeapptemplate") && !line.includes("urchasesAreCompletedBy")) return []; const regex = new RegExp(PROTECTED_REFERENCE_SOURCE, "g"); return [...line.matchAll(regex)].map((m) => [m.index, m.index + m[0].length]); } diff --git a/src/validation/scenarios/queue.ts b/src/validation/scenarios/queue.ts index 2247a75..2022bba 100644 --- a/src/validation/scenarios/queue.ts +++ b/src/validation/scenarios/queue.ts @@ -52,6 +52,12 @@ export type QueueScenarioInputs = { export type Platform = "ios" | "android"; +// Buttons that close the dialogs that can cover the list while the walk waits +// for the seeded "Sample" entry: the paid iOS template's notification reason +// alert (OK), the OS permission prompt (許可 / Allow), the Keychain sheet +// (今はしない / Not Now) and the multi-tenancy modal's second phase (Cancel). +export const SAMPLE_WAIT_DISMISS: readonly string[] = ["OK", "許可", "Allow", "今はしない", "Not Now", "Cancel"]; + export function buildQueueScenario( domain: DomainSpec, inputs: QueueScenarioInputs, @@ -261,7 +267,15 @@ export function buildQueueScenario( // The substrate's seed name "Sample" doesn't get renamed (it's // a value, not a domain identifier). On every edition the list // shows an entry containing "Sample" — wait for it. - { kind: "wait_for_text", text: "Sample", timeoutMs: 15_000 }, + // + // The paid iOS template (2026-10-25) says what notifications are for in + // an alert of its own ("Notifications let you know…" / OK) before the OS + // prompt, and it can arrive after the optional taps above have given up + // (observed: the Sample wait timed out with the poll seeing only the + // alert's 2 elements). So this wait dismisses, in order of appearance, + // that alert, the OS prompt, the Keychain sheet and the multi-tenancy + // modal whenever a poll doesn't see the list. + { kind: "wait_for_text", text: "Sample", timeoutMs: 15_000, dismiss: SAMPLE_WAIT_DISMISS }, { kind: "screenshot", label: "04-primary-list-with-sample" }, // Drill into the sample resource to reach the queue-entry list. diff --git a/src/validation/stage2.ts b/src/validation/stage2.ts index 3414e92..76bf14f 100644 --- a/src/validation/stage2.ts +++ b/src/validation/stage2.ts @@ -26,7 +26,14 @@ import type { MobileClient, ScreenElement } from "../mobile.js"; // PR 3 of the Stage 2 series. export type Stage2Step = - | { kind: "wait_for_text"; text: string; timeoutMs?: number; exact?: boolean } + // dismiss: button labels (whole text, case-insensitive) to tap whenever a + // poll doesn't find `text` — for dialogs that can surface at any moment + // during the wait (the paid iOS template's notification reason alert, the + // OS permission prompt behind it, the Keychain "Save password?" sheet). + // An alert hides the screen behind it from the accessibility tree, so a + // one-off optional tap placed before the wait misses an alert that arrives + // a second later. + | { kind: "wait_for_text"; text: string; timeoutMs?: number; exact?: boolean; dismiss?: readonly string[] } // optional:true swallows wait/find failures and just continues. Use // for system overlays that appear conditionally (e.g. iOS Keychain // "Save password?" prompt after signup) — you want to dismiss the @@ -164,7 +171,7 @@ type RunStepArgs = { async function runStep(a: RunStepArgs): Promise { switch (a.step.kind) { case "wait_for_text": { - await waitForText(a.client, a.step.text, a.step.timeoutMs ?? a.waitMs, a.pollMs, undefined, a.step.exact); + await waitForText(a.client, a.step.text, a.step.timeoutMs ?? a.waitMs, a.pollMs, undefined, a.step.exact, a.step.dismiss); return undefined; } case "tap_text": { @@ -288,6 +295,7 @@ async function waitForText( pollMs: number, preferType?: string, exact?: boolean, + dismiss?: readonly string[], ): Promise { const deadline = Date.now() + timeoutMs; let lastSeen = 0; @@ -296,6 +304,7 @@ async function waitForText( lastSeen = elements.length; const match = findByText(elements, text, preferType, exact); if (match) return match; + if (await dismissOneOf(client, elements, dismiss)) continue; await sleep(pollMs); } throw new Error( @@ -303,6 +312,21 @@ async function waitForText( ); } +async function dismissOneOf( + client: MobileClient, + elements: readonly ScreenElement[], + labels: readonly string[] | undefined, +): Promise { + for (const label of labels ?? []) { + const button = findByText(elements, label, "Button", true); + const center = button && centerOf(button); + if (!center) continue; + await client.click(center.x, center.y); + return true; + } + return false; +} + // Recover from the app's intermittent "Something went wrong" error screen. // Its "Back to Start Screen" button logs out, resets login info, and returns to // the welcome screen — a clean state to (re)start the walk from. Returns true diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 5bbc341..e3fb50a 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -4529,3 +4529,128 @@ test("report carries the run cost; HTML and CLI line show it", async () => { // Total row: 2 calls, 2761+3456 = 6,217 in, 814+2866 = 3,680 out assert.match(html, /Total<\/td>2<\/td>6,217<\/td>3,680<\/td>17,320<\/td>86,600<\/td>\$0\.2197<\/td><\/tr>/); }); + +// --- Third-party identifiers that a state rename must not touch (0.4.4) --- + +test("rename.rb leaves RevenueCat's PurchasesAreCompletedBy and the Solid Queue schema alone when the plan renames Completed", { skip: !RUBY_AVAILABLE }, async () => { + // Observed 2026-10-27 (agent 0.4.3, Orange Baton): the planner added Completed→Passed and the + // Android build failed with "Unresolved reference 'PurchasesArePassedBy'", and + // db/queue_schema.rb got a passed_jobs column Solid Queue doesn't know. Expected values: + // the identifier as the RevenueCat purchases-android SDK declares it + // (com.revenuecat.purchases.PurchasesAreCompletedBy), and the column as Solid Queue's + // batches migration declares it (completed_jobs). The template's own CompletedTag still + // follows the plan. + const kotlin = [ + "import com.revenuecat.purchases.PurchasesAreCompletedBy.REVENUECAT", + "val tag = CompletedTag()", + " .purchasesAreCompletedBy(REVENUECAT)", + ].join("\n"); + const root = await runRenameFixture( + { + "app/src/main/kotlin/PurchasesIdentity.kt": kotlin, + "db/queue_schema.rb": ' t.integer "completed_jobs", default: 0, null: false', + "db/queue_migrate/20261005225505_add_batches_to_solid_queue.rb": " t.integer :completed_jobs, default: 0, null: false", + "db/schema.rb": ' t.datetime "completed_at"', + }, + [{ from: "Completed", to: "Passed" }], + ); + assert.deepEqual(readFileSync(join(root, "app/src/main/kotlin/PurchasesIdentity.kt"), "utf8").split("\n"), [ + "import com.revenuecat.purchases.PurchasesAreCompletedBy.REVENUECAT", + "val tag = PassedTag()", + " .purchasesAreCompletedBy(REVENUECAT)", + ]); + assert.equal(readFileSync(join(root, "db/queue_schema.rb"), "utf8"), ' t.integer "completed_jobs", default: 0, null: false'); + assert.equal( + readFileSync(join(root, "db/queue_migrate/20261005225505_add_batches_to_solid_queue.rb"), "utf8"), + " t.integer :completed_jobs, default: 0, null: false", + ); + // The app's own schema is not a gem's: its column follows the plan as before. + assert.equal(readFileSync(join(root, "db/schema.rb"), "utf8"), ' t.datetime "passed_at"'); +}); + +test("runLayer1 mirrors rename.rb: PurchasesAreCompletedBy is a protected span and the Solid Queue schema is skipped", async () => { + const { protectedSpans } = await import("../src/validation/protected-references.js"); + // "import com.revenuecat.purchases." is 32 characters; the identifier is 23. + assert.deepEqual(protectedSpans("import com.revenuecat.purchases.PurchasesAreCompletedBy.REVENUECAT"), [[32, 55]]); + assert.deepEqual(protectedSpans(" .purchasesAreCompletedBy(REVENUECAT)"), [[3, 26]]); + assert.deepEqual(protectedSpans("val tag = CompletedTag()"), []); + + const root = mkdtempSync(join(tmpdir(), "layer1-third-party-")); + mkdirSync(join(root, "db", "queue_migrate"), { recursive: true }); + writeFileSync(join(root, "PurchasesIdentity.kt"), [ + "import com.revenuecat.purchases.PurchasesAreCompletedBy.REVENUECAT", + "val tag = CompletedTag()", + ].join("\n")); + writeFileSync(join(root, "db", "queue_schema.rb"), "# Completed stays here: Solid Queue's file"); + writeFileSync(join(root, "db", "queue_migrate", "001_batches.rb"), "# Completed stays here too"); + writeFileSync(join(root, "db", "schema.rb"), "# Completed in the app's own schema is a leftover"); + const result = await runLayer1({ projectDir: root, forbiddenTokens: ["Completed"] }); + assert.deepEqual( + result.findings.map((f) => [f.file, f.line, f.token]), + [["PurchasesIdentity.kt", 2, "Completed"], ["db/schema.rb", 1, "Completed"]], + ); +}); + +test("wait_for_text taps a listed dismiss button whenever a poll doesn't see the text", async () => { + // Observed 2026-10-27 (paid iOS, agent 0.4.3): the notification reason alert covered the + // list and the poll saw only its 2 elements, so wait_for_text "Sample" timed out although + // "Sample Project" was behind it. The OK button at (100, 100) 100×40 has its center at + // (150, 120). + const { runStage2Scenario } = await import("../src/validation/stage2.js"); + const alert = [ + { label: "Notifications let you know when someone in your organization completes batons.", type: "StaticText", x: 0, y: 0, width: 300, height: 60 }, + { label: "OK", type: "Button", x: 100, y: 100, width: 100, height: 40 }, + ]; + const list = [{ label: "Sample Project", type: "StaticText", x: 0, y: 0, width: 300, height: 40 }]; + const fakeClient = () => { + const clicks: { x: number; y: number }[] = []; + let dismissed = false; + const client = { + listElements: async () => (dismissed ? list : alert), + click: async (x: number, y: number) => { + clicks.push({ x, y }); + dismissed = true; + }, + }; + return { client: client as unknown as import("../src/mobile.js").MobileClient, clicks }; + }; + const screenshotDir = mkdtempSync(join(tmpdir(), "stage2-dismiss-")); + + const withDismiss = fakeClient(); + const ok = await runStage2Scenario({ + client: withDismiss.client, + scenario: { name: "dismiss", steps: [{ kind: "wait_for_text", text: "Sample", timeoutMs: 2_000, dismiss: ["Cancel", "OK"] }] }, + screenshotDir, + pollIntervalMs: 10, + defaultWaitMs: 500, + }); + assert.equal(ok.ok, true, ok.steps[0]?.error); + assert.deepEqual(withDismiss.clicks, [{ x: 150, y: 120 }]); + + const without = fakeClient(); + const failed = await runStage2Scenario({ + client: without.client, + scenario: { name: "no-dismiss", steps: [{ kind: "wait_for_text", text: "Sample", timeoutMs: 100 }] }, + screenshotDir, + pollIntervalMs: 10, + defaultWaitMs: 500, + }); + assert.equal(failed.ok, false); + assert.match(failed.steps[0]?.error ?? "", /last poll saw 2 elements/); + assert.deepEqual(without.clicks, []); +}); + +test("buildQueueScenario's Sample wait dismisses the dialogs that can cover the list, the notification reason alert first", async () => { + const { buildQueueScenario, SAMPLE_WAIT_DISMISS } = await import("../src/validation/scenarios/queue.js"); + for (const platform of ["ios", "android"] as const) { + const scenario = buildQueueScenario( + { slug: "vet-clinic", displayName: "Vet Clinic", entities: [], renamePlan: [{ from: "Shop", to: "Clinic" }, { from: "Shopkeeper", to: "Vet" }], jsonApiContract: {} }, + { fullName: "T", email: "x@y.z", password: "p", primaryResourceName: "Acme", railsOutDir: "/tmp/r" }, + platform, + ); + const sampleWait = scenario.steps.find((s) => s.kind === "wait_for_text" && s.text === "Sample"); + assert.deepEqual(sampleWait, { kind: "wait_for_text", text: "Sample", timeoutMs: 15_000, dismiss: SAMPLE_WAIT_DISMISS }, platform); + } + // The alert's OK comes first: it sits above the OS prompt it precedes. + assert.deepEqual(SAMPLE_WAIT_DISMISS, ["OK", "許可", "Allow", "今はしない", "Not Now", "Cancel"]); +});