diff --git a/doc/DEEP_LINKS.md b/doc/DEEP_LINKS.md index 7dcf1a94..1a103ad9 100644 --- a/doc/DEEP_LINKS.md +++ b/doc/DEEP_LINKS.md @@ -127,9 +127,32 @@ All deep links follow the format: `timesafari:///?` ### App Routes +These take no parameters. + - `timesafari://` — no route at all opens the app at the home feed. - `timesafari://account` +- `timesafari://discover` + - Query params, all optional: + - `searchText`: prefills the search box + - `searchPeople`: any value switches to the people tab + - `hideOnboarding`: "true" suppresses the onboarding prompt +- `timesafari://invite-one` - `timesafari://new-activity` +- `timesafari://onboard-meeting-list` +- `timesafari://projects` +- `timesafari://recent-offers-to-user` +- `timesafari://recent-offers-to-user-projects` +- `timesafari://search-area` +- `timesafari://share-my-contact-info` +- `timesafari://statistics` + +### Help Routes + +- `timesafari://help` +- `timesafari://help-notifications` +- `timesafari://help-notification-types` +- `timesafari://help-onboarding` +- `timesafari://help-terms` ### Claim Routes diff --git a/jest.config.js b/jest.config.js index 7914d35b..353bc9fd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,9 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + // Unit tests live under src/. test-playwright/ holds Playwright specs, + // which use a different runner and fail if Jest collects them. + roots: ['/src'], moduleFileExtensions: ['ts', 'js', 'json', 'vue'], transform: { '^.+\\.ts$': 'ts-jest' diff --git a/package.json b/package.json index b1088b96..ea8dfb37 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,10 @@ "type-check:vue": "vue-tsc --noEmit", "prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js", "test:prerequisites": "node scripts/check-prerequisites.js", + "test:unit": "jest", "check:dependencies": "./scripts/check-dependencies.sh", "deps:update-daily-notification-plugin": "npm install @timesafari/daily-notification-plugin@git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master", - "test:all": "npm run lint && tsc && npm run test:web && npm run test:mobile && ./scripts/test-safety-check.sh && echo '\n\n\nGotta add the performance tests'", + "test:all": "npm run lint && tsc && npm run test:unit && npm run test:web && npm run test:mobile && ./scripts/test-safety-check.sh && echo '\n\n\nGotta add the performance tests'", "test:web": "npx playwright test -c playwright.config-local.ts --trace on", "test:mobile": "./scripts/test-mobile.sh", "test:android": "node scripts/test-android.js", diff --git a/src/interfaces/deepLinks.ts b/src/interfaces/deepLinks.ts index 203a1604..e78e8f96 100644 --- a/src/interfaces/deepLinks.ts +++ b/src/interfaces/deepLinks.ts @@ -63,23 +63,44 @@ export const deepLinkPathSchemas = { did: z.object({ did: z.string().optional(), }), + discover: z.object({}), + help: z.object({}), + "help-notification-types": z.object({}), + "help-notifications": z.object({}), + "help-onboarding": z.object({}), + "help-terms": z.object({}), + "invite-one": z.object({}), "invite-one-accept": z.object({ // optional because A) it could be a query param, and B) the page displays an input if things go wrong jwt: z.string().optional(), }), "new-activity": z.object({}), + "onboard-meeting-list": z.object({}), "onboard-meeting-members": z.object({ groupId: z.string(), }), project: z.object({ id: z.string(), }), + projects: z.object({}), + "recent-offers-to-user": z.object({}), + "recent-offers-to-user-projects": z.object({}), + "search-area": z.object({}), + "share-my-contact-info": z.object({}), + statistics: z.object({}), "user-profile": z.object({ id: z.string(), }), }; export const deepLinkQuerySchemas = { + // All optional: the view defaults searchText to "" and the two flags to + // false, so a bare "timesafari://discover" is valid. + discover: z.object({ + searchText: z.string().optional(), + searchPeople: z.string().optional(), + hideOnboarding: z.string().optional(), + }), "onboard-meeting-members": z.object({ password: z.string(), }), diff --git a/src/router/index.ts b/src/router/index.ts index 01d1db12..643cc8dd 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -33,11 +33,6 @@ const routes: Array = [ name: "claim-cert", component: () => import("../views/ClaimCertificateView.vue"), }, - { - path: "/confirm-contact", - name: "confirm-contact", - component: () => import("../views/ConfirmContactView.vue"), - }, { path: "/confirm-gift/:id?", name: "confirm-gift", diff --git a/src/test/deepLinkRoutes.test.ts b/src/test/deepLinkRoutes.test.ts new file mode 100644 index 00000000..68ed9f71 --- /dev/null +++ b/src/test/deepLinkRoutes.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from "fs"; +import { join } from "path"; + +import { deepLinkPathSchemas } from "@/interfaces/deepLinks"; + +/** + * Every deep link path must name a route the router actually serves. + * + * The two lists are maintained by hand in separate files, so nothing but this + * test stops them drifting apart. A path that names a missing route reaches the + * user as a link that validates, navigates, and lands nowhere. + * + * The router is read as text rather than imported: it builds a real Router at + * module scope and pulls in every view, which Jest's node environment cannot + * load. + */ + +const ROUTER_SOURCE = join(__dirname, "..", "router", "index.ts"); + +/** Deep link paths that intentionally resolve to a differently named route. */ +const ALIASES: Record = { + // Must match ROUTE_NAME_RESOLVERS in src/services/deepLinks.ts + "": "home", +}; + +function routerRouteNames(): Set { + const source = readFileSync(ROUTER_SOURCE, "utf8"); + const names = [...source.matchAll(/^\s+name: "([^"]+)",$/gm)].map( + (match) => match[1], + ); + return new Set(names); +} + +describe("deep link paths", () => { + const routeNames = routerRouteNames(); + + it("parses the router source", () => { + // Guards against a silent pass if the router's formatting changes and the + // pattern above stops matching. + expect(routeNames.size).toBeGreaterThan(40); + expect(routeNames.has("home")).toBe(true); + }); + + it.each(Object.keys(deepLinkPathSchemas))( + 'path "%s" resolves to a real route', + (path) => { + expect(routeNames).toContain(ALIASES[path] ?? path); + }, + ); + + it("aliases only paths that need one", () => { + for (const path of Object.keys(ALIASES)) { + expect(Object.keys(deepLinkPathSchemas)).toContain(path); + expect(routeNames.has(path)).toBe(false); + } + }); +}); diff --git a/src/views/ConfirmContactView.vue b/src/views/ConfirmContactView.vue deleted file mode 100644 index 672802de..00000000 --- a/src/views/ConfirmContactView.vue +++ /dev/null @@ -1,66 +0,0 @@ - - - diff --git a/src/views/ProjectViewView.vue b/src/views/ProjectViewView.vue index a2027c27..556c51cb 100644 --- a/src/views/ProjectViewView.vue +++ b/src/views/ProjectViewView.vue @@ -813,6 +813,16 @@ export default class ProjectViewView extends Vue { async created() { this.notify = createNotifyHelpers(this.$notify); + // Read the project id before the first await. The offer and gift buttons + // render as soon as activeDid and isRegistered are set below, and they hand + // this id to their dialogs. Leaving it empty across an await lets a click + // land in that window and record an offer with no project attached, which + // the server then files against the user instead. + const pathParam = window.location.pathname.substring("/project/".length); + if (pathParam) { + this.projectId = decodeURIComponent(pathParam); + } + const settings = await this.$accountSettings(); // Get activeDid from active_identity table (single source of truth) @@ -838,10 +848,6 @@ export default class ProjectViewView extends Vue { ); } - const pathParam = window.location.pathname.substring("/project/".length); - if (pathParam) { - this.projectId = decodeURIComponent(pathParam); - } this.loadProject(this.projectId, this.activeDid); this.loadTotals(); diff --git a/test-playwright/50-record-offer.spec.ts b/test-playwright/50-record-offer.spec.ts index f8463ceb..72305b50 100644 --- a/test-playwright/50-record-offer.spec.ts +++ b/test-playwright/50-record-offer.spec.ts @@ -20,7 +20,12 @@ test('Record an offer', async ({ page }) => { await page.getByTestId('closeOnboardingAndFinish').click(); await page.locator('ul#listDiscoverResults li:nth-child(1)').click(); // Record an offer - await page.locator('button', { hasText: 'Edit' }).isVisible(); // since the 'edit' takes longer to show, wait for that (lest the click miss) + // The line that used to sit here waited for a button with the text 'Edit'. It + // never waited (isVisible() returns at once) and never matched (that button + // carries title="Edit", no text, and renders only for the project's issuer). + // The project id is read from the URL, so wait for the navigation to land. + await page.waitForURL(/\/project\//); + await expect(page.getByTestId('offerButton')).toBeVisible(); await page.getByTestId('offerButton').click(); await page.getByTestId('inputDescription').fill(description); await page.getByTestId('inputOfferAmount').locator('input').fill(randomNonZeroNumber.toString()); @@ -48,8 +53,9 @@ test('Record an offer', async ({ page }) => { await page.getByRole('link', { name: 'Offers', exact: true }).click(); await page.locator('li').filter({ hasText: description }).locator('a').first().click(); await page.getByTestId('editClaimButton').click(); - await page.locator('heading', { hasText: 'What is offered' }).isVisible(); - const itemDesc = await page.getByTestId('itemDescription'); + // ('heading' is not an element name and isVisible() does not wait, so the line + // that used to sit here matched nothing; toHaveValue below polls properly.) + const itemDesc = page.getByTestId('itemDescription'); await expect(itemDesc).toHaveValue(description); const amount = await page.getByTestId('inputOfferAmount'); await expect(amount).toHaveValue(randomNonZeroNumber.toString());