feat: add tips

This commit is contained in:
zhom
2026-09-09 06:16:14 -07:00
parent 3a0e5a41c0
commit a01901ef07
83 changed files with 8032 additions and 2732 deletions
+4
View File
@@ -28,6 +28,10 @@ export const commandCoverage = {
"window_decorations::get_window_decoration_layout",
"get_onboarding_completed",
"complete_onboarding",
"get_tips_state",
"mark_tip_seen",
"set_tips_auto_show",
"observe_cloud_plan",
"data_root::get_data_root_info",
"data_root::move_data_root",
"data_root::clear_data_root_choice",
+8
View File
@@ -71,6 +71,7 @@ export class AppSession {
seedDownloadedBrowser = false,
onboardingCompleted = true,
wayfernTermsAccepted = true,
settings = {},
}) {
this.name = name;
this.root = root;
@@ -84,6 +85,8 @@ export class AppSession {
this.seedDownloadedBrowser = seedDownloadedBrowser;
this.onboardingCompleted = onboardingCompleted;
this.wayfernTermsAccepted = wayfernTermsAccepted;
// Extra keys for the seeded app_settings.json, on top of the defaults.
this.settings = settings;
this.session = null;
}
@@ -138,6 +141,10 @@ export class AppSession {
commercial_trial_acknowledged: true,
window_resize_warning_dismissed: true,
disable_auto_updates: true,
// A tip opening by itself mid-test is a modal nobody asked for;
// the tips suite turns it back on for the one session that wants it.
tips_auto_show: false,
...this.settings,
},
null,
2,
@@ -591,6 +598,7 @@ export function appFromEnvironment(name, options = {}) {
seedDownloadedBrowser: options.seedDownloadedBrowser,
onboardingCompleted: options.onboardingCompleted,
wayfernTermsAccepted: options.wayfernTermsAccepted,
settings: options.settings,
});
}
+78
View File
@@ -71,11 +71,89 @@ test("fresh app renders, completes onboarding, persists settings, and never touc
assert.ok(system && typeof system === "object");
assert.equal(typeof (await app.invoke("read_log_files")), "string");
// Feature tips: what was seen, the one-a-day pacing, and the decision
// behind the paid-plan welcome all live in the settings file.
const tips = await app.invoke("get_tips_state");
assert.equal(tips.auto_show, true);
assert.deepEqual(tips.seen, []);
assert.equal(tips.auto_due, true, "a fresh install owes its first tip");
const marked = await app.invoke("mark_tip_seen", {
tipId: "dnsBlocklist",
auto: true,
});
assert.deepEqual(marked.seen, ["dnsBlocklist"]);
assert.equal(typeof marked.last_auto_shown_at, "number");
assert.equal(marked.auto_due, false, "one automatic tip a day");
const browsed = await app.invoke("mark_tip_seen", {
tipId: "proxyCheck",
auto: false,
});
assert.deepEqual(browsed.seen, ["dnsBlocklist", "proxyCheck"]);
assert.equal(
browsed.last_auto_shown_at,
marked.last_auto_shown_at,
"a browsed tip must not restart the pacing",
);
const quiet = await app.invoke("set_tips_auto_show", { enabled: false });
assert.equal(quiet.auto_show, false);
assert.equal(quiet.auto_due, false);
assert.equal(
await app.invoke("observe_cloud_plan", {
userId: "acct-free",
paid: false,
freshLogin: true,
}),
false,
"a free account is never greeted",
);
assert.equal(
await app.invoke("observe_cloud_plan", {
userId: "acct-free",
paid: true,
freshLogin: false,
}),
true,
"free to paid is the upgrade the welcome exists for",
);
assert.equal(
await app.invoke("observe_cloud_plan", {
userId: "acct-free",
paid: true,
freshLogin: true,
}),
false,
"and it is greeted once",
);
assert.equal(
await app.invoke("observe_cloud_plan", {
userId: "acct-web",
paid: true,
freshLogin: true,
}),
true,
"a paid account first seen right after signing in came from checkout",
);
assert.equal(
await app.invoke("observe_cloud_plan", {
userId: "acct-old",
paid: true,
freshLogin: false,
}),
false,
"a paid account in an old session is not new to its plan",
);
await app.restart();
const afterRestart = await app.invoke("get_app_settings");
assert.equal(afterRestart.theme, "dark");
assert.equal(afterRestart.language, "en");
assert.equal(afterRestart.onboarding_completed, true);
assert.deepEqual(afterRestart.tips_seen, ["dnsBlocklist", "proxyCheck"]);
assert.equal(afterRestart.tips_auto_show, false);
assert.deepEqual((await app.invoke("get_tips_state")).seen, [
"dnsBlocklist",
"proxyCheck",
]);
const settingsFile = path.join(
app.dataRoot,
+307
View File
@@ -2495,3 +2495,310 @@ test("the synchroniser panel lists a live session and its controls act on the re
}
});
});
const TIPS_DIALOG = '[data-slot="tips-dialog"]';
async function openTipsFromRail(app) {
await app.clickSelector('[aria-label="More"]');
await app.waitFor(
() =>
app.execute(`return Boolean(document.querySelector("[role='menu']"));`),
{ description: "More menu" },
);
await app.clickSelector('[data-slot="rail-open-tips"]');
await app.waitFor(
() =>
app.execute(
`return document.querySelector(arguments[0])?.dataset.mode === "browse";`,
[TIPS_DIALOG],
),
{ description: "the tips catalog" },
);
}
test("tips open from the rail, walk the catalog, and deep-link into the feature", async () => {
await withApp("ui-tips-browse", async (app) => {
await openTipsFromRail(app);
assert.ok(await app.visibleTextIncludes(en.tips.items.dnsBlocklist.title));
// Every essential is listed; a plan tip needs a plan, and there is none.
const listed = await app.execute(
`return [...document.querySelectorAll('[data-slot="tips-list-item"]')].map((node) => node.dataset.tipId);`,
);
assert.ok(listed.includes("dnsBlocklist"));
assert.ok(listed.includes("trash"));
assert.ok(!listed.includes("cookieBot"));
assert.ok(!listed.includes("team"));
// The drawing is live SVG in the scene panel, not a picture.
assert.equal(
await app.execute(
`return document.querySelectorAll('[data-slot="tip-scene-panel"] svg[data-slot="tip-scene"]').length;`,
),
1,
);
await app.clickSelector('[data-slot="tip-next"]');
await app.waitFor(
() =>
app.execute(
`return document.querySelector('[data-slot="tip-detail"]')?.dataset.tipId === "proxyCheck";`,
),
{ description: "the second tip" },
);
assert.ok(await app.visibleTextIncludes(en.tips.items.proxyCheck.title));
// Picking from the catalog moves the sliding indicator onto that entry.
await app.clickSelector(
'[data-slot="tips-list-item"][data-tip-id="trash"]',
);
await app.waitFor(
() =>
app.execute(
`return Boolean(document.querySelector('[data-slot="tips-list-item"][data-tip-id="trash"] [data-slot="tips-list-indicator"]'));`,
),
{ description: "the indicator on the chosen tip" },
);
assert.ok(await app.visibleTextIncludes(en.tips.items.trash.title));
assert.equal(
await app.execute(
`return document.querySelectorAll('[data-slot="tips-list-indicator"]').length;`,
),
1,
"exactly one entry is marked current",
);
await app.capture("tips-browse");
// Seen tips are remembered, so the automatic flow never repeats them.
await app.waitFor(
async () => {
const state = await app.invoke("get_tips_state");
return ["dnsBlocklist", "proxyCheck", "trash"].every((id) =>
state.seen.includes(id),
);
},
{ description: "seen tips persisted" },
);
// The action lands inside the feature: the DNS tip opens settings on
// its DNS section, and the dialog is gone by then.
await app.clickSelector(
'[data-slot="tips-list-item"][data-tip-id="dnsBlocklist"]',
);
await app.clickSelector('[data-slot="tip-action"]');
await app.waitFor(
() =>
app.execute(
`return document.activeElement?.dataset?.settingsSection === "dns";`,
),
{ description: "the DNS settings section focused" },
);
assert.equal(
await app.execute(
`return Boolean(document.querySelector(arguments[0]));`,
[TIPS_DIALOG],
),
false,
);
// The chord opens the catalog too, and the switch turns the automatic
// flow off and persists that.
await app.pressShortcut({
...(process.platform === "darwin" ? { meta: true } : { ctrl: true }),
shift: true,
key: "h",
});
await app.waitFor(
() =>
app.execute(
`return document.querySelector(arguments[0])?.dataset.mode === "browse";`,
[TIPS_DIALOG],
),
{ description: "the tips catalog from the keyboard" },
);
assert.equal(
await app.execute(
`return document.querySelector('[data-slot="tips-auto-show"]').getAttribute("data-state");`,
),
"unchecked",
"the harness seeds the automatic flow off",
);
await app.clickSelector('[data-slot="tips-auto-show"]');
await app.waitFor(
async () => (await app.invoke("get_tips_state")).auto_show === true,
{ description: "the preference persisted" },
);
await dismissSurface(app);
await app.waitFor(
() =>
app.execute(`return !document.querySelector(arguments[0]);`, [
TIPS_DIALOG,
]),
{ description: "the dialog closed" },
);
});
});
test("a tip opens by itself once the app settles, then waits a day", async () => {
await withApp(
"ui-tips-auto",
async (app) => {
await app.waitFor(
() =>
app.execute(
`return document.querySelector(arguments[0])?.dataset.mode === "single";`,
[TIPS_DIALOG],
),
{ description: "the automatic tip", timeoutMs: 30_000 },
);
assert.ok(
await app.visibleTextIncludes(en.tips.items.dnsBlocklist.title),
);
assert.equal(
await app.execute(
`return document.querySelectorAll('[data-slot="tips-list-item"]').length;`,
),
0,
"the single card carries no catalog",
);
await app.capture("tips-auto");
await app.waitFor(
async () => {
const state = await app.invoke("get_tips_state");
return (
state.seen.includes("dnsBlocklist") && state.auto_due === false
);
},
{ description: "the automatic tip recorded" },
);
await dismissSurface(app);
await app.waitFor(
() =>
app.execute(`return !document.querySelector(arguments[0]);`, [
TIPS_DIALOG,
]),
{ description: "the dialog closed" },
);
// A restart within the day shows nothing: one tip a day.
await app.restart();
await app.waitForText("No profiles yet");
await new Promise((resolve) => setTimeout(resolve, 4_500));
assert.equal(
await app.execute(
`return Boolean(document.querySelector(arguments[0]));`,
[TIPS_DIALOG],
),
false,
);
},
{ settings: { tips_auto_show: true } },
);
});
test("a freshly paid account is welcomed once and walked to its plan tips", async () => {
await withApp("ui-paid-welcome", async (app) => {
await app.waitForText("No profiles yet");
try {
// A pro account that signed in a moment ago, as the desktop would hold
// it after a device-code login. Only the IPC read of the cached user is
// stubbed; the plan observation and the tips state run for real.
await stubCommand(app, "cloud_get_user", {
logged_in_at: new Date().toISOString(),
user: {
id: "ui-paid-welcome",
email: "paid@example.test",
plan: "pro",
planPeriod: "monthly",
subscriptionStatus: "active",
profileLimit: 50,
cloudProfilesUsed: 0,
proxyBandwidthLimitMb: 0,
proxyBandwidthUsedMb: 0,
proxyBandwidthExtraMb: 0,
isPrimaryDevice: true,
},
});
await app.invoke("plugin:event|emit", {
event: "cloud-auth-changed",
payload: null,
});
await app.waitFor(
() =>
app.execute(
`return Boolean(document.querySelector('[data-slot="paid-welcome"]'));`,
),
{ description: "the paid welcome" },
);
assert.ok(
await app.visibleTextIncludes(
en.paidWelcome.title.replace("{{plan}}", "Pro"),
),
);
assert.deepEqual(
await app.execute(
`return [...document.querySelectorAll('[data-slot="paid-welcome-item"]')].map((node) => node.dataset.tipId);`,
),
["cloudBackup", "cookieBot", "crossOs", "automation", "agent"],
"every capability the plan grants, in catalog order, and nothing it lacks",
);
await app.capture("paid-welcome");
// A row opens the catalog on that tip, with the plan tips now listed.
await app.clickSelector(
'[data-slot="paid-welcome-item"][data-tip-id="cookieBot"]',
);
await app.waitFor(
() =>
app.execute(
`return document.querySelector('[data-slot="tip-detail"]')?.dataset.tipId === "cookieBot";`,
),
{ description: "the Cookie Bot tip" },
);
assert.equal(
await app.execute(
`return Boolean(document.querySelector('[data-slot="paid-welcome"]'));`,
),
false,
);
const listed = await app.execute(
`return [...document.querySelectorAll('[data-slot="tips-list-item"]')].map((node) => node.dataset.tipId);`,
);
assert.ok(listed.includes("cookieBot") && listed.includes("agent"));
assert.ok(listed.includes("dnsBlocklist"));
await app.capture("tips-plan-catalog");
await dismissSurface(app);
await app.waitFor(
() =>
app.execute(`return !document.querySelector(arguments[0]);`, [
TIPS_DIALOG,
]),
{ description: "the catalog closed" },
);
// Greeted once: the same account signing in again is not welcomed twice.
await app.invoke("plugin:event|emit", {
event: "cloud-auth-changed",
payload: null,
});
await new Promise((resolve) => setTimeout(resolve, 1_500));
assert.equal(
await app.execute(
`return Boolean(document.querySelector('[data-slot="paid-welcome"]'));`,
),
false,
);
assert.equal(
await app.invoke("observe_cloud_plan", {
userId: "ui-paid-welcome",
paid: true,
freshLogin: true,
}),
false,
);
} finally {
await restoreStubs(app);
}
});
});