@pantheon-systems/create-p1-starter-kit 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,12 +45,12 @@ After scaffolding:
45
45
 
46
46
  | Variable | Purpose |
47
47
  | --- | --- |
48
- | `PCC_SITE_ID` | Content Cloud site ID |
49
- | `PCC_TOKEN` | API token for your site |
50
- | `NEXT_PUBLIC_CSS_BASE_URL` | CSS API base URL |
51
48
  | `NEXT_PUBLIC_CSS_SITE_ID` | Site identifier (UUID) |
52
49
  | `CSS_API_KEY` | Server-side API key |
53
50
 
51
+ Everything else in `.env.example` is commented out and optional, including the
52
+ `PCC_SITE_ID`/`PCC_TOKEN` pair for Content Publisher.
53
+
54
54
  2. **Start the dev server:**
55
55
 
56
56
  ```bash
package/lib/cli.js CHANGED
@@ -7,31 +7,80 @@ import { copyTemplate } from './copy-template.js';
7
7
  import { detectPackageManager, isPackageManagerAvailable, installDependencies } from './install-deps.js';
8
8
  import { showWelcome, showSuccess, showInstallHelp, showError } from './messages.js';
9
9
 
10
+ const PROJECT_NAME_RULE =
11
+ 'Project name must be lowercase and can only contain letters, numbers, hyphens, and underscores';
12
+
13
+ function validateProjectName(value) {
14
+ if (!value) return 'Please enter a project name';
15
+ if (!/^[a-z0-9-_]+$/.test(value)) return PROJECT_NAME_RULE;
16
+ return undefined;
17
+ }
18
+
19
+ const PACKAGE_MANAGERS = new Set(['pnpm', 'npm', 'yarn']);
20
+
21
+ export function parseArgs(args) {
22
+ const parsed = { projectName: undefined, yes: false, pm: undefined, git: undefined, install: undefined };
23
+
24
+ for (let i = 0; i < args.length; i++) {
25
+ const arg = args[i];
26
+ if (arg === '--yes' || arg === '-y') {
27
+ parsed.yes = true;
28
+ } else if (arg === '--pm' || arg.startsWith('--pm=')) {
29
+ const value = arg === '--pm' ? args[++i] : arg.slice('--pm='.length);
30
+ if (!PACKAGE_MANAGERS.has(value)) {
31
+ throw new Error(`--pm must be one of: ${[...PACKAGE_MANAGERS].join(', ')}`);
32
+ }
33
+ parsed.pm = value;
34
+ } else if (arg === '--git' || arg === '--no-git') {
35
+ parsed.git = arg === '--git';
36
+ } else if (arg === '--install' || arg === '--no-install') {
37
+ parsed.install = arg === '--install';
38
+ } else if (arg.startsWith('-')) {
39
+ throw new Error(`Unknown option: ${arg}`);
40
+ } else if (parsed.projectName === undefined) {
41
+ parsed.projectName = arg;
42
+ } else {
43
+ throw new Error(`Unexpected argument: ${arg}`);
44
+ }
45
+ }
46
+
47
+ return parsed;
48
+ }
49
+
10
50
  export async function runCLI() {
11
51
  showWelcome();
12
52
 
13
- const args = process.argv.slice(2);
14
- const targetDirArg = args[0];
53
+ let parsed;
54
+ try {
55
+ parsed = parseArgs(process.argv.slice(2));
56
+ } catch (error) {
57
+ showError(error.message);
58
+ process.exit(1);
59
+ }
15
60
 
16
61
  clack.intro(pc.bgCyan(pc.black(' P1 Starter Kit Setup ')));
17
62
 
18
63
  // Get project name
19
- const projectName = await clack.text({
20
- message: 'What is your project named?',
21
- placeholder: 'my-p1-app',
22
- initialValue: targetDirArg || 'my-p1-app',
23
- validate: (value) => {
24
- if (!value) return 'Please enter a project name';
25
- if (!/^[a-z0-9-_]+$/.test(value)) {
26
- return 'Project name must be lowercase and can only contain letters, numbers, hyphens, and underscores';
27
- }
28
- return undefined;
29
- },
30
- });
31
-
32
- if (clack.isCancel(projectName)) {
33
- clack.cancel('Operation cancelled');
34
- process.exit(0);
64
+ let projectName;
65
+ if (parsed.yes) {
66
+ projectName = parsed.projectName || 'my-p1-app';
67
+ const problem = validateProjectName(projectName);
68
+ if (problem) {
69
+ showError(problem);
70
+ process.exit(1);
71
+ }
72
+ } else {
73
+ projectName = await clack.text({
74
+ message: 'What is your project named?',
75
+ placeholder: 'my-p1-app',
76
+ initialValue: parsed.projectName || 'my-p1-app',
77
+ validate: validateProjectName,
78
+ });
79
+
80
+ if (clack.isCancel(projectName)) {
81
+ clack.cancel('Operation cancelled');
82
+ process.exit(0);
83
+ }
35
84
  }
36
85
 
37
86
  const targetDir = path.resolve(process.cwd(), projectName);
@@ -44,47 +93,67 @@ export async function runCLI() {
44
93
 
45
94
  // Detect package manager
46
95
  const detectedPM = detectPackageManager();
47
- const packageManager = await clack.select({
48
- message: 'Which package manager do you want to use?',
49
- options: [
50
- { value: 'pnpm', label: 'pnpm', hint: detectedPM === 'pnpm' ? 'detected' : '' },
51
- { value: 'npm', label: 'npm', hint: detectedPM === 'npm' ? 'detected' : '' },
52
- { value: 'yarn', label: 'yarn', hint: detectedPM === 'yarn' ? 'detected' : '' },
53
- ],
54
- initialValue: detectedPM,
55
- });
56
-
57
- if (clack.isCancel(packageManager)) {
58
- clack.cancel('Operation cancelled');
59
- process.exit(0);
60
- }
61
-
62
- // Check if package manager is available
63
- if (!isPackageManagerAvailable(packageManager)) {
64
- showError(`${packageManager} is not installed. Please install it first or choose a different package manager.`);
65
- process.exit(1);
96
+ let packageManager;
97
+ if (parsed.pm) {
98
+ packageManager = parsed.pm;
99
+ } else if (parsed.yes) {
100
+ packageManager = detectedPM;
101
+ } else {
102
+ packageManager = await clack.select({
103
+ message: 'Which package manager do you want to use?',
104
+ options: [
105
+ { value: 'pnpm', label: 'pnpm', hint: detectedPM === 'pnpm' ? 'detected' : '' },
106
+ { value: 'npm', label: 'npm', hint: detectedPM === 'npm' ? 'detected' : '' },
107
+ { value: 'yarn', label: 'yarn', hint: detectedPM === 'yarn' ? 'detected' : '' },
108
+ ],
109
+ initialValue: detectedPM,
110
+ });
111
+
112
+ if (clack.isCancel(packageManager)) {
113
+ clack.cancel('Operation cancelled');
114
+ process.exit(0);
115
+ }
66
116
  }
67
117
 
68
118
  // Git init?
69
- const shouldInitGit = await clack.confirm({
70
- message: 'Initialize a git repository?',
71
- initialValue: true,
72
- });
73
-
74
- if (clack.isCancel(shouldInitGit)) {
75
- clack.cancel('Operation cancelled');
76
- process.exit(0);
119
+ let shouldInitGit;
120
+ if (parsed.git !== undefined) {
121
+ shouldInitGit = parsed.git;
122
+ } else if (parsed.yes) {
123
+ shouldInitGit = true;
124
+ } else {
125
+ shouldInitGit = await clack.confirm({
126
+ message: 'Initialize a git repository?',
127
+ initialValue: true,
128
+ });
129
+
130
+ if (clack.isCancel(shouldInitGit)) {
131
+ clack.cancel('Operation cancelled');
132
+ process.exit(0);
133
+ }
77
134
  }
78
135
 
79
136
  // Install deps?
80
- const shouldInstall = await clack.confirm({
81
- message: 'Install dependencies now?',
82
- initialValue: true,
83
- });
84
-
85
- if (clack.isCancel(shouldInstall)) {
86
- clack.cancel('Operation cancelled');
87
- process.exit(0);
137
+ let shouldInstall;
138
+ if (parsed.install !== undefined) {
139
+ shouldInstall = parsed.install;
140
+ } else if (parsed.yes) {
141
+ shouldInstall = true;
142
+ } else {
143
+ shouldInstall = await clack.confirm({
144
+ message: 'Install dependencies now?',
145
+ initialValue: true,
146
+ });
147
+
148
+ if (clack.isCancel(shouldInstall)) {
149
+ clack.cancel('Operation cancelled');
150
+ process.exit(0);
151
+ }
152
+ }
153
+
154
+ if (shouldInstall && !isPackageManagerAvailable(packageManager)) {
155
+ showError(`${packageManager} is not installed. Please install it first or choose a different package manager.`);
156
+ process.exit(1);
88
157
  }
89
158
 
90
159
  const s = clack.spinner();
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseArgs } from './cli.js';
3
+
4
+ describe('parseArgs', () => {
5
+ it('treats the first bare argument as the project name', () => {
6
+ expect(parseArgs(['my-app'])).toMatchObject({ projectName: 'my-app', yes: false });
7
+ });
8
+
9
+ it('defaults every choice to undefined so prompts still run', () => {
10
+ expect(parseArgs([])).toEqual({
11
+ projectName: undefined,
12
+ yes: false,
13
+ pm: undefined,
14
+ git: undefined,
15
+ install: undefined,
16
+ });
17
+ });
18
+
19
+ it('accepts --yes and -y', () => {
20
+ expect(parseArgs(['--yes']).yes).toBe(true);
21
+ expect(parseArgs(['-y']).yes).toBe(true);
22
+ });
23
+
24
+ it('accepts --pm as a separate value or with =', () => {
25
+ expect(parseArgs(['--pm', 'pnpm']).pm).toBe('pnpm');
26
+ expect(parseArgs(['--pm=npm']).pm).toBe('npm');
27
+ });
28
+
29
+ it('rejects an unknown package manager', () => {
30
+ expect(() => parseArgs(['--pm', 'bun'])).toThrow(/--pm must be one of/);
31
+ expect(() => parseArgs(['--pm'])).toThrow(/--pm must be one of/);
32
+ });
33
+
34
+ it('parses git and install toggles', () => {
35
+ expect(parseArgs(['--git']).git).toBe(true);
36
+ expect(parseArgs(['--no-git']).git).toBe(false);
37
+ expect(parseArgs(['--install']).install).toBe(true);
38
+ expect(parseArgs(['--no-install']).install).toBe(false);
39
+ });
40
+
41
+ it('rejects unknown options and extra positionals', () => {
42
+ expect(() => parseArgs(['--frobnicate'])).toThrow(/Unknown option/);
43
+ expect(() => parseArgs(['app-one', 'app-two'])).toThrow(/Unexpected argument/);
44
+ });
45
+
46
+ it('parses a full non-interactive invocation', () => {
47
+ expect(parseArgs(['my-app', '--yes', '--pm', 'pnpm', '--no-git', '--no-install'])).toEqual({
48
+ projectName: 'my-app',
49
+ yes: true,
50
+ pm: 'pnpm',
51
+ git: false,
52
+ install: false,
53
+ });
54
+ });
55
+ });
@@ -14,6 +14,40 @@ export function getScaffolderVersion() {
14
14
  return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
15
15
  }
16
16
 
17
+ // npm strips files named `.gitignore` from published tarballs, so the template
18
+ // ships it undotted (see scripts/build-template.js) and the name is restored here
19
+ // — before the CLI's initial `git add -A`.
20
+ const GITIGNORE_TEMPLATE_NAME = 'gitignore';
21
+ const PROJECT_NAME_PLACEHOLDER = 'PLACEHOLDER_PROJECT_NAME';
22
+
23
+ // Acted on directly rather than guarded by an existsSync: a check-then-use pair
24
+ // is a file-system race, and the failure it would catch is exactly what the
25
+ // rename already reports.
26
+ export function restoreGitignore(targetDir) {
27
+ try {
28
+ fs.renameSync(
29
+ path.join(targetDir, GITIGNORE_TEMPLATE_NAME),
30
+ path.join(targetDir, '.gitignore')
31
+ );
32
+ } catch (error) {
33
+ if (error.code !== 'ENOENT') throw error;
34
+ throw new Error(
35
+ `Template is missing ${GITIGNORE_TEMPLATE_NAME}; scaffolding without it would commit node_modules.`
36
+ );
37
+ }
38
+ }
39
+
40
+ function stampProjectName(filePath, projectName) {
41
+ let contents;
42
+ try {
43
+ contents = fs.readFileSync(filePath, 'utf-8');
44
+ } catch (error) {
45
+ if (error.code !== 'ENOENT') throw error;
46
+ throw new Error(`Template is missing ${path.basename(filePath)}.`);
47
+ }
48
+ fs.writeFileSync(filePath, contents.replaceAll(PROJECT_NAME_PLACEHOLDER, projectName));
49
+ }
50
+
17
51
  export function copyTemplate(targetDir, projectName) {
18
52
  const templatePath = getTemplatePath();
19
53
 
@@ -22,6 +56,9 @@ export function copyTemplate(targetDir, projectName) {
22
56
  }
23
57
 
24
58
  copyRecursive(templatePath, targetDir);
59
+ restoreGitignore(targetDir);
60
+
61
+ stampProjectName(path.join(targetDir, 'README.md'), projectName);
25
62
 
26
63
  // Stamp the project name and scaffolder version into package.json
27
64
  const packageJsonPath = path.join(targetDir, 'package.json');
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
4
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
- import { copyTemplate, getScaffolderVersion } from './copy-template.js';
5
+ import { copyTemplate, getScaffolderVersion, restoreGitignore } from './copy-template.js';
6
6
 
7
7
  describe('copyTemplate', () => {
8
8
  let targetDir;
@@ -32,3 +32,44 @@ describe('getScaffolderVersion', () => {
32
32
  expect(getScaffolderVersion()).toBe(manifest.version);
33
33
  });
34
34
  });
35
+
36
+ describe('gitignore', () => {
37
+ let targetDir;
38
+
39
+ beforeEach(() => {
40
+ targetDir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'scaffold-')), 'my-app');
41
+ });
42
+
43
+ afterEach(() => {
44
+ fs.rmSync(path.dirname(targetDir), { recursive: true, force: true });
45
+ });
46
+
47
+ it('restores the dotted name so the initial commit excludes node_modules', () => {
48
+ copyTemplate(targetDir, 'my-app');
49
+
50
+ expect(fs.existsSync(path.join(targetDir, 'gitignore'))).toBe(false);
51
+ expect(fs.readFileSync(path.join(targetDir, '.gitignore'), 'utf-8')).toContain('/node_modules');
52
+ });
53
+
54
+ it('refuses to scaffold from a template with no gitignore', () => {
55
+ const stripped = fs.mkdtempSync(path.join(os.tmpdir(), 'no-gitignore-'));
56
+
57
+ expect(() => restoreGitignore(stripped)).toThrow(/missing gitignore/);
58
+
59
+ fs.rmSync(stripped, { recursive: true, force: true });
60
+ });
61
+ });
62
+
63
+ describe('README', () => {
64
+ it('stamps the project name into the scaffolded README', () => {
65
+ const targetDir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'scaffold-')), 'acme-site');
66
+
67
+ copyTemplate(targetDir, 'acme-site');
68
+ const readme = fs.readFileSync(path.join(targetDir, 'README.md'), 'utf-8');
69
+
70
+ expect(readme).toContain('# acme-site');
71
+ expect(readme).not.toContain('PLACEHOLDER_PROJECT_NAME');
72
+
73
+ fs.rmSync(path.dirname(targetDir), { recursive: true, force: true });
74
+ });
75
+ });
package/lib/messages.js CHANGED
@@ -12,7 +12,7 @@ export function showSuccess(projectName, _projectPath, packageManager) {
12
12
  console.log(` ${pc.cyan('cd')} ${projectName}`);
13
13
  console.log(` ${pc.dim('# Copy .env.example to .env and fill in your credentials:')}`);
14
14
  console.log(` ${pc.cyan('cp')} .env.example .env`);
15
- console.log(` ${pc.dim('# Edit .env with your PCC_SITE_ID and PCC_TOKEN')}\n`);
15
+ console.log(` ${pc.dim('# Edit .env with your NEXT_PUBLIC_CSS_SITE_ID and CSS_API_KEY')}\n`);
16
16
  const devCmd = packageManager === 'npm' ? 'npm run dev' : `${packageManager} dev`;
17
17
  console.log(` ${pc.dim('# Start the dev server:')}`);
18
18
  console.log(` ${pc.cyan(devCmd)}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pantheon-systems/create-p1-starter-kit",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Scaffold a new P1 starter project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,9 +38,9 @@
38
38
  "isomorphic-dompurify": "^3.18.0",
39
39
  "react": "^19.2.5",
40
40
  "vitest": "^4.1.5",
41
- "@pantheon-systems/css-client": "0.12.0",
41
+ "@pantheon-systems/css-client": "0.13.0",
42
42
  "@pantheon-systems/eslint-config": "0.1.0",
43
- "@pantheon-systems/puck-css": "0.12.0"
43
+ "@pantheon-systems/puck-css": "0.13.0"
44
44
  },
45
45
  "dependencies": {
46
46
  "@clack/prompts": "^1.5.1",
@@ -53,6 +53,7 @@
53
53
  "clean": "rm -rf template node_modules/.vite",
54
54
  "lint": "eslint index.js lib scripts",
55
55
  "lint:fix": "eslint index.js lib scripts --fix",
56
- "test": "vitest run"
56
+ "test": "vitest run",
57
+ "test:scaffold": "node scripts/validate-scaffold.js"
57
58
  }
58
59
  }
@@ -0,0 +1,82 @@
1
+ # PLACEHOLDER_PROJECT_NAME
2
+
3
+ A Next.js site built on Pantheon's Content Publisher — a Puck-based visual page editor with
4
+ server-side datasource resolution, so authors build dynamic pages without writing code.
5
+
6
+ Scaffolded with [`@pantheon-systems/create-p1-starter-kit`](https://www.npmjs.com/package/@pantheon-systems/create-p1-starter-kit).
7
+
8
+ ## Getting started
9
+
10
+ 1. Copy the environment file and fill in your credentials:
11
+
12
+ ```bash
13
+ cp .env.example .env
14
+ ```
15
+
16
+ Two variables are required:
17
+
18
+ | Variable | Description |
19
+ | ------------------------- | -------------------------------------------------------------------- |
20
+ | `NEXT_PUBLIC_CSS_SITE_ID` | The Pantheon Site ID (Next.js, etc.) for this P1 site |
21
+ | `CSS_API_KEY` | The P1 API Token generated by the P1 platform to access your P1 site |
22
+
23
+ `.env.example` documents the optional ones inline — the backend URL
24
+ (`NEXT_PUBLIC_CSS_BASE_URL`) for staging or local backends.
25
+
26
+ 2. Install dependencies and start the dev server:
27
+
28
+ ```bash
29
+ npm install
30
+ npm run dev
31
+ ```
32
+
33
+ 3. Open http://localhost:3000 for the site, or http://localhost:3000/p1 for the page dashboard.
34
+
35
+ Additional step-by-step details are in the [P1 developer guide](https://live-p1-docs.pantheonsite.io/dev-guide/dev-guide-getting-started).
36
+
37
+ ## Scripts
38
+
39
+ | Script | What it does |
40
+ | ----------------------- | ----------------------------------------------- |
41
+ | `npm run dev` | Start the Next.js dev server |
42
+ | `npm run build` | Production build |
43
+ | `npm start` | Serve the production build |
44
+ | `npm test` | Run the Vitest suite |
45
+ | `npm run lint` | Lint with the bundled flat ESLint config |
46
+ | `npm run typecheck` | Type-check without emitting |
47
+ | `npm run sync:registry` | Push the Puck component registry to the backend |
48
+
49
+ ## Project structure
50
+
51
+ ```
52
+ app/
53
+ page.tsx # Site root (renders via Puck)
54
+ layout.tsx # Root layout
55
+ styles.css # Tailwind entry point
56
+ [...puckPath]/ # Catch-all route for published pages
57
+ p1/
58
+ (editor)/[[...p1]]/ # Dashboard at /p1, editor at /p1/<path>
59
+ api/[...p1]/route.ts # API handler for page data
60
+ auth/[...action]/route.ts # Login / logout callbacks
61
+ merge/ # Branch merge review UI
62
+ components/puck/ # Block definitions (Heading, Paragraph, Image, …)
63
+ lib/remote-datasources.ts # Datasource registry (REMOTE_DATASOURCE_REGISTRY)
64
+ lib/remote-datasource-fetchers.ts # Fetchers backing those datasources
65
+ puck.config.tsx # Puck editor configuration — block registry & categories
66
+ ```
67
+
68
+ ## Customizing
69
+
70
+ - **Add a block** — create a component in `components/puck/`, then register it in
71
+ `puck.config.tsx`.
72
+ - **Add a datasource** — add an entry to `REMOTE_DATASOURCE_REGISTRY` in
73
+ `lib/remote-datasources.ts`, with its fetcher in `lib/remote-datasource-fetchers.ts`, to make
74
+ external data available to block fields through `{{ datasource.field }}` expressions.
75
+ - **Change styling** — the project uses Tailwind CSS v4; edit `app/styles.css` or the individual
76
+ block components.
77
+
78
+ ## Updating
79
+
80
+ The scaffolder records the version it generated from under `p1.templateVersion` in
81
+ `package.json`. Compare it against the latest published `@pantheon-systems/create-p1-starter-kit`
82
+ to see whether newer starter-kit changes are available.
@@ -14,7 +14,7 @@ describe("auth route does not force re-authentication on every login", () => {
14
14
 
15
15
  it("does not hardcode an OAuth prompt override", () => {
16
16
  // prompt: 'login' forces Google's full re-auth screen on every broker
17
- // login, even with a live Google session — see PCC-3391.
17
+ // login, even with a live Google session.
18
18
  expect(content).not.toMatch(/prompt\s*:\s*['"]login['"]/);
19
19
  });
20
20
 
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { buildPageMetadata } from "../lib/seo-metadata";
3
3
 
4
4
  /**
5
- * PCC-3407: buildPageMetadata maps the head metadata inputs (client-derived
5
+ * buildPageMetadata maps the head metadata inputs (client-derived
6
6
  * title/description/canonical plus the backend-supplied siteName) onto the
7
7
  * Next.js Metadata object that renders the per-page <head> tags. Next replaces
8
8
  * (not deep-merges) a page's openGraph over the layout's, so og:type and the
@@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const appDir = resolve(__dirname, "..");
8
8
 
9
- // PCC-3499 / PCC-3513: Puck's collectStyles() copies every parent
9
+ // Puck's collectStyles() copies every parent
10
10
  // <style>/<link> element into the canvas-preview iframe verbatim (there is
11
11
  // no exclusion API), and separately its CopyHostStyles helper syncs this
12
12
  // document's <body> attributes (including `class`) onto the iframe's own
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { LogoutOutcome } from "@pantheon-systems/puck-css";
3
+ import { runWidgetLogout } from "../app/[...puckPath]/widget-logout";
4
+
5
+ // Records the effects in order, so each test asserts the whole sequence a
6
+ // logout attempt produces rather than one call in isolation.
7
+ function recorder(logout: () => Promise<LogoutOutcome>) {
8
+ const calls: string[] = [];
9
+ return {
10
+ calls,
11
+ fx: {
12
+ logout,
13
+ navigate: (url: string) => calls.push(`navigate:${url}`),
14
+ reload: () => calls.push("reload"),
15
+ setBusy: (busy: boolean) => calls.push(`busy:${busy}`),
16
+ setError: (message: string | null) =>
17
+ calls.push(`error:${message ?? "cleared"}`),
18
+ },
19
+ };
20
+ }
21
+
22
+ const LOGOUT_URL = "https://example.auth0.com/v2/logout?client_id=abc";
23
+
24
+ describe("runWidgetLogout", () => {
25
+ it("navigates to the Auth0 logout URL when the session ended", async () => {
26
+ const { calls, fx } = recorder(async () => ({
27
+ status: "signed_out",
28
+ logoutUrl: LOGOUT_URL,
29
+ }));
30
+
31
+ await runWidgetLogout(fx);
32
+
33
+ // Stays busy: the navigation replaces the page, so releasing the button
34
+ // would only flash it back to "Log out" on the way out.
35
+ expect(calls).toEqual([
36
+ "busy:true",
37
+ "error:cleared",
38
+ `navigate:${LOGOUT_URL}`,
39
+ ]);
40
+ });
41
+
42
+ it("keeps the menu open and shows why when logout failed", async () => {
43
+ const { calls, fx } = recorder(async () => ({
44
+ status: "error",
45
+ message: "Broker logout failed (503)",
46
+ }));
47
+
48
+ await runWidgetLogout(fx);
49
+
50
+ // Still signed in and retryable, so the button must come back.
51
+ expect(calls).toEqual([
52
+ "busy:true",
53
+ "error:cleared",
54
+ "error:Broker logout failed (503)",
55
+ "busy:false",
56
+ ]);
57
+ });
58
+
59
+ it("reloads when there was no session to end", async () => {
60
+ const { calls, fx } = recorder(async () => ({ status: "no_session" }));
61
+
62
+ await runWidgetLogout(fx);
63
+
64
+ expect(calls).toEqual(["busy:true", "error:cleared", "reload"]);
65
+ });
66
+
67
+ it("reports a thrown error instead of leaving the button stuck", async () => {
68
+ const { calls, fx } = recorder(async () => {
69
+ throw new Error("Failed to fetch");
70
+ });
71
+
72
+ await runWidgetLogout(fx);
73
+
74
+ expect(calls).toEqual([
75
+ "busy:true",
76
+ "error:cleared",
77
+ "error:Failed to fetch",
78
+ "busy:false",
79
+ ]);
80
+ });
81
+
82
+ it("falls back to a generic message when something non-Error is thrown", async () => {
83
+ const { calls, fx } = recorder(async () => {
84
+ throw "socket closed";
85
+ });
86
+
87
+ await runWidgetLogout(fx);
88
+
89
+ expect(calls).toEqual([
90
+ "busy:true",
91
+ "error:cleared",
92
+ "error:Logout failed",
93
+ "busy:false",
94
+ ]);
95
+ });
96
+
97
+ it("clears a previous failure before retrying", async () => {
98
+ const { calls, fx } = recorder(async () => ({
99
+ status: "signed_out",
100
+ logoutUrl: LOGOUT_URL,
101
+ }));
102
+
103
+ await runWidgetLogout(fx);
104
+ await runWidgetLogout(fx);
105
+
106
+ // The second attempt clears the slot again; a stale message must not sit
107
+ // under a logout that is now succeeding.
108
+ expect(calls.slice(3)).toEqual([
109
+ "busy:true",
110
+ "error:cleared",
111
+ `navigate:${LOGOUT_URL}`,
112
+ ]);
113
+ });
114
+ });