Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions scripts/ruby/rename.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)\]"'<>`]*)?
|
(?<![A-Za-z0-9_-])nativeapptemplate-agent(?:-mcp)?(?![A-Za-z0-9_])
|
(?<![A-Za-z0-9_])[Pp]urchasesAreCompletedBy(?![A-Za-z0-9_])
}x
MASK_OPEN = "\u{E000}"
MASK_CLOSE = "\u{E001}"
Expand Down
12 changes: 9 additions & 3 deletions src/validation/layer1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ const SKIP_SEGMENTS = new Set([
// deliberately does NOT rewrite these (they're regenerated from un-renamed
// source by the bundler — e.g. Turbo's `visitCompleted` in application.js), so
// Layer 1 must not scan them either, or it flags tokens we intentionally left.
const SKIP_SUBPATHS = ["app/assets/builds", "public/assets"];
// The Solid Queue / Cache / Cable schemas and migrations are skipped for the
// same reason: rename.rb leaves the gems' columns (completed_jobs) alone.
const SKIP_SUBPATHS = [
"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",
];

// Ordinary words that begin with a forbidden token but aren't the domain noun.
// rename.rb leaves them alone (the token is followed by a lowercase letter, not
Expand Down Expand Up @@ -120,9 +126,9 @@ async function* walk(dir: string, root: string): AsyncGenerator<string> {
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;
Expand Down
6 changes: 4 additions & 2 deletions src/validation/protected-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`(?<![A-Za-z0-9_-])nativeapptemplate-agent(?:-mcp)?(?![A-Za-z0-9_])`,
String.raw`(?<![A-Za-z0-9_])[Pp]urchasesAreCompletedBy(?![A-Za-z0-9_])`,
].join("|");

export function protectedSpans(line: string): Array<[start: number, end: number]> {
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]);
}
16 changes: 15 additions & 1 deletion src/validation/scenarios/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
28 changes: 26 additions & 2 deletions src/validation/stage2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -164,7 +171,7 @@ type RunStepArgs = {
async function runStep(a: RunStepArgs): Promise<string | undefined> {
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": {
Expand Down Expand Up @@ -288,6 +295,7 @@ async function waitForText(
pollMs: number,
preferType?: string,
exact?: boolean,
dismiss?: readonly string[],
): Promise<ScreenElement> {
const deadline = Date.now() + timeoutMs;
let lastSeen = 0;
Expand All @@ -296,13 +304,29 @@ 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(
`wait_for_text "${text}": not found within ${timeoutMs}ms (last poll saw ${lastSeen} elements)`,
);
}

async function dismissOneOf(
client: MobileClient,
elements: readonly ScreenElement[],
labels: readonly string[] | undefined,
): Promise<boolean> {
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
Expand Down
125 changes: 125 additions & 0 deletions tests/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<tr><td>Total<\/td><td>2<\/td><td>6,217<\/td><td>3,680<\/td><td>17,320<\/td><td>86,600<\/td><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"]);
});
Loading