Public: anyone can read this; the crew can change it.
The terminal shows your profile and sets your display name with `n` and email with `e`, in the account page's words #8144
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8001-the-terminal-shows-your-profile-and-sets → main 3 commits fork 3 threads · 3 unresolved
8 files
changed +352
−10
README.md +2
| @@ -530,3 +530,5 @@ The web changelog's days and its "shipped today" count follow the reader's clock | ||
| 530 | 530 | A repository admin cuts a wave from two doors, both through `cutWaveFor` (`src/actions/cut_wave.ts`): Settings → *Cut a wave*, and `c` on the terminal's waves screen, which says how many pulls would go in, asks `cut wave N? y/n` (`y` and `n` choose outright) and shows the draft pull's number (Enter opens it). Each opens a draft pull listing the pulls merged since the last wave; nothing merged, an earlier wave that does not parse, or a viewer who is not an admin is refused in the action's words, writing nothing. |
| 531 | 531 | |
| 532 | 532 | Cmd-K finds a repository by its name first (exact, then prefix; your own ahead); a repository with an app has an "Open the app of …" option under it, and Cmd/Ctrl+Enter on the repository opens the app (`docs/features/cmd-k-repositories.md`). |
| 533 | ||
| 534 | In the terminal, the palette's **Your profile** (any signed-in person, never an agent; a guest is not offered it) shows username, display name and email; `n` sets the display name and `e` the email through `updateProfile`, the action behind Settings → Account. Refusals read as the page's do (`src/actions/profile_words.ts`, including one shared fallback for a refusal with no words of its own), and a changed email says a confirmation link was sent to it and that it is unverified until followed. |
src/actions/profile_words.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * What a person is told when `updateProfile` refuses: one sentence per error, | |
| 3 | * shared by Settings › Account and the terminal's profile screen so the two | |
| 4 | * say the same thing. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { updateProfile } from "./users.ts"; | |
| 8 | ||
| 9 | type ProfileError = Extract<ReturnType<typeof updateProfile>, { ok: false }>["error"]; | |
| 10 | ||
| 11 | /** The refusals a person can fix by writing something else. */ | |
| 12 | export const PROFILE_REFUSALS: Partial<Record<ProfileError, string>> = { | |
| 13 | email_taken: "Another account already uses that address.", | |
| 14 | invalid_email: "That doesn't look like an email address.", | |
| 15 | display_name_reserved: "That name belongs to the crew, the owner or the forge.", | |
| 16 | display_name_mixed_script: | |
| 17 | "Write it in one alphabet: letters from different scripts can pass for other names.", | |
| 18 | }; | |
| 19 | ||
| 20 | /** The form field a refusal belongs to. */ | |
| 21 | export const PROFILE_REFUSAL_FIELD: Partial<Record<ProfileError, "displayName" | "email">> = { | |
| 22 | email_taken: "email", | |
| 23 | invalid_email: "email", | |
| 24 | display_name_reserved: "displayName", | |
| 25 | display_name_mixed_script: "displayName", | |
| 26 | }; | |
| 27 | ||
| 28 | /** Any refusal not named above (a failed sign-in, a vanished account): nothing the person typed is at fault. */ | |
| 29 | export const PROFILE_FALLBACK = "Your profile could not be saved. Sign in again and try once more."; | |
| 30 | ||
| 31 | /** After a new address is saved: the link goes to it, and the old proof no longer stands. */ | |
| 32 | export const emailSavedWords = (email: string): string => | |
| 33 | `Email saved. A confirmation link was sent to ${email}; it is not verified until you follow it.`; |
src/tui/screens/entries.ts +2
| @@ -27,6 +27,7 @@ import { modelCeilingEntry, modelGrantEntry } from "./model.ts"; | ||
| 27 | 27 | import { entry as orgTeams } from "./orgteams.ts"; |
| 28 | 28 | import { entry as owner } from "./owner.ts"; |
| 29 | 29 | import { entry as people } from "./people.ts"; |
| 30 | import { entry as profile } from "./profile.ts"; | |
| 30 | 31 | import { entry as quotas } from "./quotas.ts"; |
| 31 | 32 | import { entry as resilience } from "./resilience.ts"; |
| 32 | 33 | import { entry as scorecard } from "./scorecard.ts"; |
| @@ -83,6 +84,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 83 | 84 | front, |
| 84 | 85 | owner, |
| 85 | 86 | forges, |
| 87 | profile, | |
| 86 | 88 | ]; |
| 87 | 89 | |
| 88 | 90 | /** Put each entry the session is offered into `commands`, after the command it names or at the end. */ |
src/tui/screens/profile.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | /** | |
| 2 | * Your profile, in the terminal: username, display name and email, with `n` | |
| 3 | * and `e` to change the last two through `updateProfile` — the action behind | |
| 4 | * Settings › Account, refusing in the same words. A new email starts | |
| 5 | * verification exactly as it does on the web. | |
| 6 | */ | |
| 7 | ||
| 8 | import { emailSavedWords, PROFILE_FALLBACK, PROFILE_REFUSALS } from "../../actions/profile_words.ts"; | |
| 9 | import { updateProfile } from "../../actions/users.ts"; | |
| 10 | import { isStandInEmail } from "../../core/users.ts"; | |
| 11 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 12 | import { binding } from "../app/keys.ts"; | |
| 13 | import type { Session } from "../session.ts"; | |
| 14 | import { style } from "../term/style.ts"; | |
| 15 | import { truncate } from "../term/width.ts"; | |
| 16 | import { promptScreen } from "./common.ts"; | |
| 17 | import type { PaletteEntry } from "./entries.ts"; | |
| 18 | ||
| 19 | export function profileScreen(f: Session, ctx: Ctx): Screen { | |
| 20 | const save = (patch: { displayName: string } | { email: string }): string | undefined => { | |
| 21 | const r = updateProfile(f.app, f.user, patch); | |
| 22 | if (!r.ok) { | |
| 23 | const words = PROFILE_REFUSALS[r.error] ?? PROFILE_FALLBACK; | |
| 24 | ctx.toast(words, "danger"); | |
| 25 | return words; | |
| 26 | } | |
| 27 | const emailChanged = "email" in patch && r.value.email.toLowerCase() !== f.user?.email.toLowerCase(); | |
| 28 | f.user = r.value; | |
outdated
After an email change say a confirmation link was sent; the web page does. |
||
| 29 | ctx.toast(emailChanged ? emailSavedWords(r.value.email) : "saved", "success"); | |
| 30 | ctx.invalidate(); | |
| 31 | return undefined; | |
| 32 | }; | |
| 33 | ||
| 34 | const screen: Screen = { | |
| 35 | title: "Your profile", | |
| 36 | keys: [binding(["n"], "set display name"), binding(["e"], "set email")], | |
| 37 | render(area, s) { | |
| 38 | const c = ctx.theme.c; | |
| 39 | const u = f.user; | |
| 40 | if (u === undefined) return; | |
| 41 | const email = isStandInEmail(u.email) ? "none" : u.email; | |
| 42 | const rows: [string, string][] = [ | |
| 43 | ["Username", u.username], | |
| 44 | ["Display name", u.displayName], | |
| 45 | ["Email", email], | |
| 46 | ]; | |
| 47 | rows.forEach(([k, v], i) => { | |
| 48 | const y = area.y + i; | |
| 49 | s.text(area.x, y, k.padEnd(14), style(c.mutedFg, c.bg), area.x + area.w); | |
| 50 | s.text(area.x + 14, y, truncate(v, Math.max(0, area.w - 14)), style(c.fg, c.bg), area.x + area.w); | |
| 51 | }); | |
| 52 | }, | |
| 53 | key(e) { | |
| 54 | if (e.ctrl || e.alt || f.user === undefined) return false; | |
| 55 | if (e.key === "n") { | |
| 56 | ctx.push( | |
| 57 | promptScreen(ctx, screen, { | |
| 58 | title: "Display name", | |
| 59 | label: "display name", | |
| 60 | value: f.user.displayName, | |
| 61 | onSubmit: (v) => save({ displayName: v }), | |
| 62 | }), | |
| 63 | ); | |
| 64 | return true; | |
| 65 | } | |
| 66 | if (e.key === "e") { | |
| 67 | ctx.push( | |
| 68 | promptScreen(ctx, screen, { | |
| 69 | title: "Email", | |
| 70 | label: "email address", | |
| 71 | value: isStandInEmail(f.user.email) ? "" : f.user.email, | |
| 72 | onSubmit: (v) => save({ email: v }), | |
| 73 | }), | |
| 74 | ); | |
| 75 | return true; | |
| 76 | } | |
| 77 | return false; | |
| 78 | }, | |
| 79 | }; | |
| 80 | return screen; | |
| 81 | } | |
| 82 | ||
| 83 | export const entry: PaletteEntry = { | |
| 84 | id: "profile", | |
| 85 | title: "Your profile", | |
| 86 | hint: "display name and email", | |
| 87 | offered: (session) => session.user !== undefined && session.user.kind !== "agent", | |
| 88 | run: (session, ctx) => ctx.push(profileScreen(session, ctx)), | |
outdated
Needs a README line and a guest test; offered() is the only gate. |
||
| 89 | }; |
src/web/pages/account.ts +10 −10
| @@ -33,6 +33,11 @@ import { linkEdgeLogin, linkedLogin, unlinkEdgeLogin } from "../../core/edge_lin | ||
| 33 | 33 | import { LAST_WAY_IN, linkedParent, parentIssuerOf, unlinkParent } from "../../core/federation.ts"; |
| 34 | 34 | import { activeEdgeToken, edgeIdentity } from "../../http/edge.ts"; |
| 35 | 35 | import { listKeys } from "../../core/keys.ts"; |
| 36 | import { | |
| 37 | PROFILE_FALLBACK, | |
| 38 | PROFILE_REFUSAL_FIELD, | |
| 39 | PROFILE_REFUSALS, | |
| 40 | } from "../../actions/profile_words.ts"; | |
| 36 | 41 | import { endAccountSession } from "../../actions/signins.ts"; |
| 37 | 42 | import { listSessions, type SessionInfo } from "../../core/sessions.ts"; |
| 38 | 43 | import { ADMIN_TOKEN_WORDS, listTokens, type TokenScope } from "../../core/tokens.ts"; |
| @@ -100,7 +105,7 @@ interface ProfileForm { | ||
| 100 | 105 | displayName: string; |
| 101 | 106 | email: string; |
| 102 | 107 | commitEmail: CommitEmail; |
| 103 | errors?: { displayName?: string; email?: string }; | |
| 108 | errors?: { displayName?: string; email?: string; form?: string }; | |
| 104 | 109 | } |
| 105 | 110 | |
| 106 | 111 | /** The profile form as the account stands now. */ |
| @@ -279,6 +284,7 @@ function profileView(ctx: Ctx, values: ProfileForm): Html { | ||
| 279 | 284 | const user = ctx.auth.user!; |
| 280 | 285 | return html`${unverifiedNotice(ctx)}${roleView(ctx)}${card( |
| 281 | 286 | html`<form method="post" action="/settings"> |
| 287 | ${when(values.errors?.form, (m) => html`<p class="error-note">${m}</p>`)} | |
| 282 | 288 | <div class="field"> |
| 283 | 289 | <label for="f-username">Username</label> |
| 284 | 290 | <input |
| @@ -404,16 +410,10 @@ export async function accountUpdate(ctx: Ctx): Promise<Response> { | ||
| 404 | 410 | commitEmail, |
| 405 | 411 | }); |
| 406 | 412 | if (!updated.ok) { |
| 413 | const words = PROFILE_REFUSALS[updated.error]; | |
| 414 | const at = PROFILE_REFUSAL_FIELD[updated.error]; | |
| 407 | 415 | const errors: ProfileForm["errors"] = |
| 408 | updated.error === "email_taken" | |
| 409 | ? { email: "Another account already uses that address." } | |
| 410 | : updated.error === "invalid_email" | |
| 411 | ? { email: "That doesn't look like an email address." } | |
| 412 | : updated.error === "display_name_reserved" | |
| 413 | ? { displayName: "That name belongs to the crew, the owner or the forge." } | |
| 414 | : updated.error === "display_name_mixed_script" | |
| 415 | ? { displayName: "Write it in one alphabet: letters from different scripts can pass for other names." } | |
| 416 | : {}; | |
| 416 | words === undefined || at === undefined ? { form: PROFILE_FALLBACK } : { [at]: words }; | |
| 417 | 417 | return accountShell( |
| 418 | 418 | ctx, |
| 419 | 419 | "profile", |
test/account-profile-words.test.ts added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | /** | |
| 2 | * Settings › Account refuses a profile in the words the terminal shares | |
| 3 | * (#8001): the web page and `PROFILE_REFUSALS` cannot drift apart. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { PROFILE_REFUSALS } from "../src/actions/profile_words.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { adminCookie } from "./helpers/admin.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | let dataDir: string; | |
| 18 | let app: App; | |
| 19 | let servers: Servers; | |
| 20 | let cookie: string; | |
| 21 | ||
| 22 | beforeAll(async () => { | |
| 23 | dataDir = await mkdtemp(join(tmpdir(), "forge-profile-words-")); | |
| 24 | app = createApp({ dataDir, port: 0 }); | |
| 25 | servers = await serve(app); | |
| 26 | cookie = await adminCookie(app, { username: "alice", email: "[email protected]" }); | |
| 27 | }); | |
| 28 | afterAll(async () => { | |
| 29 | await servers.stop?.(); | |
| 30 | app.close(); | |
| 31 | await rm(dataDir, { recursive: true, force: true }); | |
| 32 | }); | |
| 33 | ||
| 34 | const post = (fields: Record<string, string>) => | |
| 35 | fetch(`${servers.url}/settings`, { | |
| 36 | method: "POST", | |
| 37 | redirect: "manual", | |
| 38 | headers: { cookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded" }, | |
| 39 | body: new URLSearchParams(fields), | |
| 40 | }); | |
| 41 | ||
| 42 | const escaped = (s: string) => s.replace(/'/g, "'").replace(/&/g, "&"); | |
| 43 | ||
| 44 | test("the account page refuses in the shared words, in the field they belong to", async () => { | |
| 45 | const reserved = await post({ displayName: "forge", email: "[email protected]" }); | |
| 46 | expect(reserved.status).toBe(422); | |
| 47 | expect(await reserved.text()).toContain(escaped(PROFILE_REFUSALS.display_name_reserved!).replace(/&#39;/g, "'")); | |
| 48 | const bad = await post({ displayName: "Alice", email: "nope" }); | |
| 49 | expect(bad.status).toBe(422); | |
| 50 | expect(await bad.text()).toContain(PROFILE_REFUSALS.invalid_email!.replace(/'/g, "'")); | |
| 51 | }); |
test/palette-roster.test.ts +2
| @@ -112,6 +112,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 112 | 112 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 113 | 113 | ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"], |
| 114 | 114 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
| 115 | ["profile", "Your profile", "display name and email"], | |
| 115 | 116 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 116 | 117 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| 117 | 118 | ["quit", "Quit", "disconnect"], |
| @@ -207,6 +208,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 207 | 208 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 208 | 209 | ["origins", "Connected origins", "the hosts your mirrors come from"], |
| 209 | 210 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 211 | ["profile", "Your profile", "display name and email"], | |
| 210 | 212 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 211 | 213 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| 212 | 214 | ["quit", "Quit", "disconnect"], |
test/tui-profile.test.ts added +163
| @@ -0,0 +1,163 @@ | ||
| 1 | /** | |
| 2 | * Your profile in the terminal (#8001): `n` sets the display name, `e` the | |
| 3 | * email, with the account page's words on refusal; an agent has no entry. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 10 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 11 | import { emailSavedWords, PROFILE_FALLBACK, PROFILE_REFUSALS } from "../src/actions/profile_words.ts"; | |
| 12 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 13 | import { type TuiIdentity } from "../src/tui/session.ts"; | |
| 14 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | describe("your profile in the terminal", () => { | |
| 21 | let dataDir: string; | |
| 22 | let forge: Forge; | |
| 23 | ||
| 24 | function boot(username: string | undefined) { | |
| 25 | const terminal = headlessTerminal(140, 32); | |
| 26 | const user = username === undefined ? undefined : getUserByName(forge.db, username)!; | |
| 27 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 28 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 29 | app.start(); | |
| 30 | const key = (k: string) => | |
| 31 | app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 32 | return { | |
| 33 | text: () => app.screen.toText(), | |
| 34 | press: async (...keys: string[]) => { | |
| 35 | keys.forEach(key); | |
| 36 | await settleApp(app); | |
| 37 | }, | |
| 38 | ctrl: async (k: string) => { | |
| 39 | app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false }); | |
| 40 | await settleApp(app); | |
| 41 | }, | |
| 42 | type: async (t: string) => { | |
| 43 | for (const ch of t) key(ch); | |
| 44 | await settleApp(app); | |
| 45 | }, | |
| 46 | stop: () => app.stop(), | |
| 47 | }; | |
| 48 | } | |
| 49 | ||
| 50 | beforeAll(async () => { | |
| 51 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-profile-")); | |
| 52 | forge = createApp({ dataDir, port: 0 }); | |
| 53 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false], ["dave", false]] as const) { | |
| 54 | const made = await createUser(forge.db, { | |
| 55 | origin: "seed", | |
| 56 | trusted: true, | |
| 57 | username, | |
| 58 | email: `${username}@forge.local`, | |
| 59 | password: "a-good-passphrase", | |
| 60 | adminIfFirst, | |
| 61 | }); | |
| 62 | if (!made.ok) throw new Error(made.error); | |
| 63 | } | |
| 64 | forge.db.query("UPDATE user SET is_agent = 1 WHERE username = 'carol'").run(); | |
| 65 | }); | |
| 66 | afterAll(async () => { | |
| 67 | forge.close(); | |
| 68 | await rm(dataDir, { recursive: true, force: true }); | |
| 69 | }); | |
| 70 | ||
| 71 | const open = async (h: ReturnType<typeof boot>) => { | |
| 72 | await h.ctrl("k"); | |
| 73 | await h.type("Your profile"); | |
| 74 | await h.press("enter"); | |
| 75 | }; | |
| 76 | const clear = async (h: ReturnType<typeof boot>, n: number) => { | |
| 77 | for (let i = 0; i < n; i++) await h.press("backspace"); | |
| 78 | }; | |
| 79 | ||
| 80 | test("n sets the display name and the screen shows it", async () => { | |
| 81 | const h = boot("bob"); | |
| 82 | await open(h); | |
| 83 | expect(h.text()).toContain("[email protected]"); | |
| 84 | await h.press("n"); | |
| 85 | await clear(h, 40); | |
| 86 | await h.type("Bobby Tables"); | |
| 87 | await h.press("enter"); | |
| 88 | expect(getUserByName(forge.db, "bob")!.displayName).toBe("Bobby Tables"); | |
| 89 | expect(h.text()).toContain("saved"); | |
| 90 | expect(h.text()).toContain("Bobby Tables"); | |
| 91 | h.stop(); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a reserved name is refused in the account page's words and changes nothing", async () => { | |
| 95 | const before = getUserByName(forge.db, "bob")!.displayName; | |
| 96 | const h = boot("bob"); | |
| 97 | await open(h); | |
| 98 | await h.press("n"); | |
| 99 | await clear(h, 40); | |
| 100 | await h.type("forge"); | |
| 101 | await h.press("enter"); | |
| 102 | expect(h.text()).toContain(PROFILE_REFUSALS.display_name_reserved!); | |
| 103 | expect(getUserByName(forge.db, "bob")!.displayName).toBe(before); | |
| 104 | h.stop(); | |
| 105 | }); | |
| 106 | ||
| 107 | test("e with a taken email toasts that refusal", async () => { | |
| 108 | const h = boot("bob"); | |
| 109 | await open(h); | |
| 110 | await h.press("e"); | |
| 111 | await clear(h, 40); | |
| 112 | await h.type("[email protected]"); | |
| 113 | await h.press("enter"); | |
| 114 | expect(h.text()).toContain("Another account already uses that address."); | |
| 115 | expect(getUserByName(forge.db, "bob")!.email).toBe("[email protected]"); | |
| 116 | h.stop(); | |
| 117 | }); | |
| 118 | ||
| 119 | test("an agent is not offered the entry", async () => { | |
| 120 | const h = boot("carol"); | |
| 121 | await h.ctrl("k"); | |
| 122 | await h.type("Your profile"); | |
| 123 | expect(h.text()).not.toContain("display name and email"); | |
| 124 | h.stop(); | |
| 125 | }); | |
| 126 | ||
| 127 | test("a new email says a confirmation link was sent to it", async () => { | |
| 128 | const h = boot("bob"); | |
| 129 | await open(h); | |
| 130 | await h.press("e"); | |
| 131 | await clear(h, 40); | |
| 132 | await h.type("[email protected]"); | |
| 133 | await h.press("enter"); | |
| 134 | expect(getUserByName(forge.db, "bob")!.email).toBe("[email protected]"); | |
| 135 | expect(h.text()).toContain(emailSavedWords("[email protected]")); | |
| 136 | expect(emailSavedWords("[email protected]")).toContain("confirmation link was sent to [email protected]"); | |
| 137 | h.stop(); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a refusal with no words of its own gets the shared fallback", async () => { | |
| 141 | const h = boot("dave"); | |
| 142 | await open(h); | |
| 143 | // The account vanishes under an open session: updateProfile answers no_such_user. | |
| 144 | forge.db.query("DELETE FROM user WHERE username = 'dave'").run(); | |
| 145 | await h.press("n"); | |
| 146 | await clear(h, 40); | |
| 147 | await h.type("Davey"); | |
| 148 | await h.press("enter"); | |
| 149 | expect(h.text()).toContain(PROFILE_FALLBACK); | |
| 150 | h.stop(); | |
| 151 | }); | |
| 152 | ||
| 153 | test("a guest is not offered the entry and cannot reach the screen", async () => { | |
| 154 | const h = boot(undefined); | |
| 155 | await h.ctrl("k"); | |
| 156 | await h.type("Your profile"); | |
| 157 | expect(h.text()).not.toContain("display name and email"); | |
| 158 | await h.press("enter"); | |
| 159 | expect(h.text()).not.toContain("set display name"); | |
| 160 | expect(h.text()).not.toContain("Display name"); | |
| 161 | h.stop(); | |
| 162 | }); | |
| 163 | }); |
new above
offline
Generic fallback is not a sentence a person can act on, and differs from the web page. Share one fallback in profile_words.ts.