From 4f3bf4b22046f8bb04e44cd79a1cfc3bbf2c43f3 Mon Sep 17 00:00:00 2001 From: Joshua Schuller <116460872+jschuller@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:48:04 -0400 Subject: [PATCH 1/5] docs(agents): require explicit approval before deploy --reinstall --reinstall uninstalls the app first and deletes everything on the instance that is not in source, including the records in the app's tables. The deploy-and-verify skill let an agent retry with it after only telling the user. The skill (both copies, via npm run sync:skills), AGENTS.md rule 2 and TROUBLESHOOTING.md now require the user's explicit approval first. --- .agents/skills/deploy-and-verify/SKILL.md | 7 +++++-- .claude/skills/deploy-and-verify/SKILL.md | 7 +++++-- AGENTS.md | 4 +++- CHANGELOG.md | 5 +++++ TROUBLESHOOTING.md | 3 ++- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.agents/skills/deploy-and-verify/SKILL.md b/.agents/skills/deploy-and-verify/SKILL.md index b33670b..5c22b6b 100644 --- a/.agents/skills/deploy-and-verify/SKILL.md +++ b/.agents/skills/deploy-and-verify/SKILL.md @@ -13,8 +13,11 @@ log or store credentials. carries the target instance's vendor prefix (instances refuse other vendors' prefixes; `npm run setup` renames it). 2. **Find the alias** — `npx now-sdk auth --list`; ask the user which alias to use if more than one fits. 3. **Install** — `npm run deploy -- --auth `. Install is asynchronous; the CLI prints a rollback link on success. - On "Could not determine app installation status", retry once with `--reinstall` after telling the user it removes - instance-only changes to the app. On "Unable to install third party application", see step 1. + On "Could not determine app installation status", check the execution tracker link first; do **not** retry with + `--reinstall` on your own. `--reinstall` uninstalls the app first, which deletes everything on the instance that is + not in source (records in the app's tables, instance-only changes). Explain that to the user, name the instance, + and wait for their explicit approval before you retry once with `--reinstall`. On "Unable to install third party + application", see step 1. 4. **ATF** — `npm run test:instance -- --auth -o json`. If it answers "Scheduled test/suite execution is disabled", the instance needs `sn_atf.runner.enabled` and then `sn_atf.schedule.enabled` set to `true` — ask the user before changing instance properties. Report the pass/fail counts and the result URL. diff --git a/.claude/skills/deploy-and-verify/SKILL.md b/.claude/skills/deploy-and-verify/SKILL.md index b33670b..5c22b6b 100644 --- a/.claude/skills/deploy-and-verify/SKILL.md +++ b/.claude/skills/deploy-and-verify/SKILL.md @@ -13,8 +13,11 @@ log or store credentials. carries the target instance's vendor prefix (instances refuse other vendors' prefixes; `npm run setup` renames it). 2. **Find the alias** — `npx now-sdk auth --list`; ask the user which alias to use if more than one fits. 3. **Install** — `npm run deploy -- --auth `. Install is asynchronous; the CLI prints a rollback link on success. - On "Could not determine app installation status", retry once with `--reinstall` after telling the user it removes - instance-only changes to the app. On "Unable to install third party application", see step 1. + On "Could not determine app installation status", check the execution tracker link first; do **not** retry with + `--reinstall` on your own. `--reinstall` uninstalls the app first, which deletes everything on the instance that is + not in source (records in the app's tables, instance-only changes). Explain that to the user, name the instance, + and wait for their explicit approval before you retry once with `--reinstall`. On "Unable to install third party + application", see step 1. 4. **ATF** — `npm run test:instance -- --auth -o json`. If it answers "Scheduled test/suite execution is disabled", the instance needs `sn_atf.runner.enabled` and then `sn_atf.schedule.enabled` set to `true` — ask the user before changing instance properties. Report the pass/fail counts and the result URL. diff --git a/AGENTS.md b/AGENTS.md index cf805b6..4fffcb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,9 @@ for the SDK (`now-sdk`, from github.com/ServiceNow/sdk) teaches the same workflo `npm run setup -- --scope x__ --name ""` once; never hand-edit the scope. 2. **Never delete a `Table()`, `Record()`, `BusinessRule()` or other record definition without asking the user.** Removing it from source makes the next install delete it on the instance (tracked in `keys.ts`). Use `Now.del()` / - `npx now-sdk explain now-del-guide` when deletion is intended. + `npx now-sdk explain now-del-guide` when deletion is intended. The same applies to `npm run deploy -- --reinstall`: + it uninstalls the app first and deletes everything on the instance that is not in source, including the records in + the app's tables. Never run it without the user's explicit approval for that instance; telling them is not enough. 3. **Commit `src/fluent/generated/keys.ts`.** It maps every `Now.ID[...]` key to a stable sys_id. Never edit it by hand, never rename an existing `Now.ID` key (that creates a new record and orphans the old one). CI builds with `--frozenKeys`. 4. **Fluent files are declarative.** Every top-level statement must be an SDK call or an `export const x = SdkCall(...)`; diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d2daa3..65cc21e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ version needs manual work to adopt it. - `SECURITY.md` explains the advisories reported in the SDK's development dependencies: they do not reach the installed app, are fixed by SDK updates, and are not overridden by default. +### Fixed +- The `deploy-and-verify` skill no longer lets an agent retry with `--reinstall` after only telling the user: it must + wait for explicit approval, because `--reinstall` uninstalls the app and deletes its table records. `AGENTS.md` + rule 2 and `TROUBLESHOOTING.md` say the same. + ## [1.0.0] - 2026-10-10 > Brazil-ready, agent-neutral, verified on an instance. diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index b758288..5caa595 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -15,7 +15,8 @@ their own company code (system property `glide.appcreator.company.code`), so the Install runs asynchronously and polls the instance. Check the execution tracker link the CLI prints (or `sys_execution_tracker` on the instance). If the upload succeeded but polling failed, retry once with `npm run deploy -- --auth --reinstall`. `--reinstall` uninstalls first, which deletes anything changed on the -instance that is not in source. +instance that is not in source, including the records in the app's tables. Coding agents must get your explicit +approval before they run it (AGENTS.md rule 2). ## `now-sdk auth` or install fails with 401 From 60f734476abf970cbc65c824651131a3de3e65ec Mon Sep 17 00:00:00 2001 From: Joshua Schuller <116460872+jschuller@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:52:08 -0400 Subject: [PATCH 2/5] test: cover scripts/setup.mjs with node:test and run it in CI setup.mjs is the first command every adopter runs and had no tests. Add npm test (node --test) and tests/setup.test.mjs, which runs setup.mjs on temporary copies of the repository, as a git checkout with an origin and as a plain directory, and checks: - the scope and name rewrite (no x_snc_example left outside the files setup skips), the new scopeId, package.json, the lockfile root, CHANGELOG.md and the removal of src/fluent/generated/; - the .gitignore-aware fallback walk outside git; - the refusals: unsafe scopes and names, a dirty tree, and a second run without --force, which must write nothing; --dry-run writes nothing. The setup tests skip themselves once the app is set up, and setup.mjs leaves tests/setup.test.mjs as written, so derived apps keep a passing npm test. CI runs npm test in the build job; docs list the command. --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/ci.yml | 4 + AGENTS.md | 3 +- CHANGELOG.md | 5 + CONTRIBUTING.md | 2 +- README.md | 3 +- RELEASING.md | 2 +- eslint.config.js | 2 +- package.json | 1 + scripts/setup.mjs | 2 + tests/setup.test.mjs | 271 +++++++++++++++++++++++++++++++ 11 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 tests/setup.test.mjs diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c3b505b..b853f3f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,7 +5,7 @@ ## Checklist -- [ ] `npm run lint` and `npm run build` pass +- [ ] `npm run lint`, `npm test` and `npm run build` pass - [ ] `src/fluent/generated/keys.ts` changes are included (no hand edits) - [ ] No `Table()`/`Record()` definitions removed without saying so above - [ ] Behaviour changes have an ATF test, and were run on a test instance diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3b4f9d..46a4cd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,10 @@ jobs: - name: Lint run: npm run lint + # Runs scripts/setup.mjs on temporary copies of the checkout (tests/setup.test.mjs). + - name: Test + run: npm test + - name: Agent skills are in sync (.agents/skills -> .claude/skills) run: npm run sync:skills -- --check diff --git a/AGENTS.md b/AGENTS.md index 4fffcb6..79ac2ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ Request flow: React page → `fetch()` with `X-UserToken: window.g_ck` → Scrip ```bash npm ci # install exact dependency versions npm run lint # ESLint (client, server, scripts) +npm test # node:test suite in tests/ (setup.mjs on a temporary copy) npm run build # compile Fluent + bundle the UI (writes src/fluent/generated/keys.ts) npm run build:ci # build --frozenKeys: fails if keys.ts is stale (CI uses this) npm run deploy -- --auth # now-sdk install to a dev/test instance @@ -106,7 +107,7 @@ for the SDK (`now-sdk`, from github.com/ServiceNow/sdk) teaches the same workflo ## Done means -- `npm run lint` and `npm run build` pass, and `keys.ts` changes are committed with the code that caused them. +- `npm run lint`, `npm test` and `npm run build` pass, and `keys.ts` changes are committed with the code that caused them. - For behaviour changes: deployed to a dev instance and `npm run test:instance` passes; new behaviour has an ATF test. - Docs that describe the change (`README.md`, this file, `TROUBLESHOOTING.md`, `CHANGELOG.md` under `[Unreleased]`) are updated in the same change. diff --git a/CHANGELOG.md b/CHANGELOG.md index 65cc21e..2966f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ version needs manual work to adopt it. ## [Unreleased] ### Added +- `npm test` (`node:test`, run in CI): `tests/setup.test.mjs` runs `scripts/setup.mjs` on temporary copies of the + repository, with and without git, and checks the scope and name rewrite, the new `scopeId`, the lockfile, + CHANGELOG and generated-keys reset, and the refusals (unsafe scopes and names, a dirty tree, a second run without + `--force`, which must write nothing). The setup tests skip themselves once an app is set up, and `setup.mjs` leaves + `tests/setup.test.mjs` as written. - Supply-chain baseline for CI and releases: `npm ci --ignore-scripts`, no npm cache in the release and instance-deploy jobs, Node 24.21.0 by exact version for releases, `.npmrc` `min-release-age=7`, Dependabot cooldowns (7 days, 14 for npm majors), job-level permissions and a zizmor job. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e06ee65..a451ab3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ possible variation. 1. Fork, then branch from `main`. 2. `npm ci`, make the change, keep the `x_snc_example` scope prefix. -3. `npm run lint && npm run build`; commit any `src/fluent/generated/keys.ts` changes with the code. +3. `npm run lint && npm test && npm run build`; commit any `src/fluent/generated/keys.ts` changes with the code. 4. If you changed `.agents/skills/`, run `npm run sync:skills`. 5. For behaviour changes, deploy to a test instance (under a scope with your vendor prefix — `npm run setup` on a scratch copy) and run the ATF suite. Say which release and patch you tested in the PR. diff --git a/README.md b/README.md index 0f9abee..da338e7 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ env_vars = ["SERVICENOW_INSTANCE_URL", "SERVICENOW_USERNAME", "SERVICENOW_PASSWO |---|---| | `npm run setup -- --scope … --name …` | Turn the template into your app (once) | | `npm run lint` | ESLint for the UI, the Script Include and the scripts | +| `npm test` | Unit tests (`node:test`) in `tests/`, including `npm run setup` on a temporary copy | | `npm run build` | Compile Fluent and bundle the UI into `dist/` | | `npm run build:ci` | Build with `--frozenKeys` (fails if `keys.ts` is stale) | | `npm run deploy -- --auth ` | Install on an instance | @@ -156,7 +157,7 @@ env_vars = ["SERVICENOW_INSTANCE_URL", "SERVICENOW_USERNAME", "SERVICENOW_PASSWO ## CI deploy (optional) -`.github/workflows/ci.yml` lints, builds with frozen keys and packages on every pull request and every push to +`.github/workflows/ci.yml` lints, runs `npm test`, builds with frozen keys and packages on every pull request and every push to `main`. Its `instance-test` job also deploys to a **test** instance on pushes to `main` and runs the ATF suite once you add the repository variable `SN_SDK_INSTANCE_URL` and the secrets `SN_SDK_OAUTH_CLIENT_ID` / `SN_SDK_OAUTH_CLIENT_SECRET` (OAuth client diff --git a/RELEASING.md b/RELEASING.md index 567bf3c..b380e76 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -15,7 +15,7 @@ instance first. On the release branch, with a clean tree: -1. `npm ci && npm run lint && npm run build:ci && npm run sync:skills -- --check` +1. `npm ci && npm run lint && npm test && npm run build:ci && npm run sync:skills -- --check` 2. Verify on an instance (a PDI is fine), in a scratch copy so the template scope stays `x_snc_example`: ```bash rsync -a --exclude node_modules --exclude .git ./ /tmp/sn-smoke/ && cd /tmp/sn-smoke && npm ci diff --git a/eslint.config.js b/eslint.config.js index ee9059d..5af61f7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -40,7 +40,7 @@ export default [ // Repository tooling (Node). { - files: ['scripts/**/*.mjs', 'eslint.config.js'], + files: ['scripts/**/*.mjs', 'tests/**/*.mjs', 'eslint.config.js'], ...js.configs.recommended, languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } }, }, diff --git a/package.json b/package.json index 20567e9..9e040be 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build": "now-sdk build", "build:ci": "now-sdk build --frozenKeys", "lint": "eslint .", + "test": "node --test \"tests/**/*.test.mjs\"", "deploy": "now-sdk install", "test:instance": "now-sdk cicd testsuite run --test-suite-name \"Example App\"", "pack": "now-sdk pack", diff --git a/scripts/setup.mjs b/scripts/setup.mjs index 282d682..423a86e 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -25,8 +25,10 @@ const SKIP_FILES = new Set([ 'RELEASING.md', 'docs/compatibility.md', 'scripts/setup.mjs', + 'tests/setup.test.mjs', join('docs', 'compatibility.md'), join('scripts', 'setup.mjs'), + join('tests', 'setup.test.mjs'), ]); const TEMPLATE_SCOPE = 'x_snc_example'; const TEMPLATE_REPO = 'jschuller/sn-app-template'; diff --git a/tests/setup.test.mjs b/tests/setup.test.mjs new file mode 100644 index 0000000..596e89d --- /dev/null +++ b/tests/setup.test.mjs @@ -0,0 +1,271 @@ +/** + * Tests for scripts/setup.mjs. Each test runs the script on a temporary copy of this repository, + * so the checkout itself is never rewritten. + * + * npm test + * + * They describe the template, so they skip themselves once `npm run setup` has made this an app of its own + * (setup.mjs leaves this file as written, like its other template-maintainer files). + */ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, sep } from 'node:path'; +import { after, describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const REPO = fileURLToPath(new URL('..', import.meta.url)); +const TEMPLATE_SCOPE = 'x_snc_example'; +const TEMPLATE_NAME = 'Example App'; +const REPO_CONFIG = JSON.parse(readFileSync(join(REPO, 'now.config.json'), 'utf8')); +const TEMPLATE_SCOPE_ID = REPO_CONFIG.scopeId; +const SKIP = REPO_CONFIG.scope === TEMPLATE_SCOPE ? false : `already set up as ${REPO_CONFIG.scope}; these tests cover the template`; +// Files setup.mjs leaves as written (its SKIP_FILES), and directories it never rewrites. +const KEEPS_TEMPLATE_SCOPE = new Set([ + 'package-lock.json', + 'RELEASING.md', + 'docs/compatibility.md', + 'scripts/setup.mjs', + 'tests/setup.test.mjs', +]); +const NOT_COPIED = new Set(['node_modules', '.git', 'dist', 'target', '.now']); +const TEXT_EXTENSIONS = /\.(ts|js|jsx|mjs|json|html|css|md|example|ya?ml)$/; + +const scratch = mkdtempSync(join(tmpdir(), 'sn-app-template-setup-')); +after(() => rmSync(scratch, { recursive: true, force: true })); + +// Keeps git from finding a repository above the scratch directory. +const env = { ...process.env, GIT_CEILING_DIRECTORIES: scratch }; + +function git(cwd, ...args) { + return execFileSync( + 'git', + ['-c', 'user.name=setup-test', '-c', 'user.email=setup-test@example.invalid', '-c', 'commit.gpgsign=false', ...args], + { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ); +} + +/** The repository's files: tracked and untracked-but-not-ignored when it is a git checkout, else a filtered walk. */ +function repoFiles() { + try { + return execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { + cwd: REPO, + encoding: 'utf8', + }) + .split('\0') + .filter((file) => file && existsSync(join(REPO, file))); + } catch { + return walk(REPO).map((path) => relative(REPO, path).split(sep).join('/')); + } +} + +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + if (NOT_COPIED.has(entry.name)) return []; + const path = join(dir, entry.name); + return entry.isDirectory() ? walk(path) : [path]; + }); +} + +let copies = 0; +/** A fresh copy of the repository; with `withGit`, a git checkout with one commit and a GitHub origin. */ +function makeCopy({ withGit }) { + const dir = join(scratch, `copy-${(copies += 1)}`); + for (const file of repoFiles()) { + const target = join(dir, file); + mkdirSync(dirname(target), { recursive: true }); + cpSync(join(REPO, file), target); + } + if (withGit) { + git(dir, 'init', '-q'); + git(dir, 'add', '-A'); + git(dir, 'commit', '-q', '-m', 'template'); + git(dir, 'remote', 'add', 'origin', 'https://github.com/acme/asset-tracker.git'); + } + return dir; +} + +function setup(dir, ...args) { + const result = spawnSync(process.execPath, [join(dir, 'scripts', 'setup.mjs'), ...args], { + cwd: dir, + env, + encoding: 'utf8', + }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +function readJson(dir, file) { + return JSON.parse(readFileSync(join(dir, file), 'utf8')); +} + +/** Text files setup.mjs is expected to have rewritten (POSIX-style paths). */ +function rewrittenTextFiles(dir) { + return walk(dir) + .map((path) => relative(dir, path).split(sep).join('/')) + .filter((file) => TEXT_EXTENSIONS.test(file) && !KEEPS_TEMPLATE_SCOPE.has(file)); +} + +/** Every file's content, keyed by path, to prove a refused run wrote nothing. */ +function snapshot(dir) { + return Object.fromEntries(walk(dir).map((path) => [relative(dir, path), readFileSync(path, 'utf8')])); +} + +describe('setup.mjs in a git checkout', { skip: SKIP }, () => { + const dir = makeCopy({ withGit: true }); + const first = setup(dir, '--scope', 'x_1234_ci', '--name', 'CI App'); + + test('succeeds', () => { + assert.equal(first.status, 0, first.stderr); + assert.match(first.stdout, /Scope x_snc_example -> x_1234_ci, name "CI App", new scopeId [0-9a-f]{32}/); + }); + + test('sets the scope and name and generates a new scopeId', () => { + const config = readJson(dir, 'now.config.json'); + assert.equal(config.scope, 'x_1234_ci'); + assert.equal(config.name, 'CI App'); + assert.match(config.scopeId, /^[0-9a-f]{32}$/); + assert.notEqual(config.scopeId, TEMPLATE_SCOPE_ID); + assert.ok(first.stdout.includes(config.scopeId), 'the printed scopeId is the one written'); + assert.equal(config.tsconfigPath, './src/server/tsconfig.json', 'other now.config.json keys are kept'); + }); + + test('leaves no template scope outside the files it deliberately skips', () => { + const leftovers = rewrittenTextFiles(dir).filter((file) => + readFileSync(join(dir, file), 'utf8').includes(TEMPLATE_SCOPE), + ); + assert.deepEqual(leftovers, []); + }); + + test('renames the app in src/ and in package.json scripts', () => { + assert.match(readFileSync(join(dir, 'src/client/components/App.jsx'), 'utf8'), /

CI App<\/h1>/); + const srcFiles = rewrittenTextFiles(dir).filter((file) => file.startsWith('src/')); + const withOldName = srcFiles.filter((file) => readFileSync(join(dir, file), 'utf8').includes(TEMPLATE_NAME)); + assert.deepEqual(withOldName, []); + const pkg = readJson(dir, 'package.json'); + assert.equal(pkg.name, 'ci-app'); + assert.equal(pkg.version, '0.1.0'); + assert.match(pkg.scripts['test:instance'], /"CI App"/); + }); + + test('updates the lockfile root, resets CHANGELOG.md and removes the generated keys', () => { + const lock = readJson(dir, 'package-lock.json'); + assert.equal(lock.name, 'ci-app'); + assert.equal(lock.packages[''].name, 'ci-app'); + assert.equal(lock.packages[''].version, '0.1.0'); + const changelog = readFileSync(join(dir, 'CHANGELOG.md'), 'utf8'); + assert.match(changelog, /All notable changes to CI App/); + assert.doesNotMatch(changelog, /\[1\.0\.0\]/); + assert.equal(existsSync(join(dir, 'src/fluent/generated')), false); + }); + + test('points template repository links at the origin remote', () => { + const security = readFileSync(join(dir, 'SECURITY.md'), 'utf8'); + assert.equal(security.includes('jschuller/sn-app-template'), false); + assert.match(first.stdout, /at acme\/asset-tracker/); + }); + + test('refuses a second run without --force and writes nothing', () => { + git(dir, 'add', '-A'); + git(dir, 'commit', '-q', '-m', 'set up'); + const before = snapshot(dir); + const second = setup(dir, '--scope', 'x_1234_other', '--name', 'Other App'); + assert.equal(second.status, 1); + assert.match(second.stderr, /already set up as x_1234_ci \("CI App"\).*Pass --force/s); + assert.deepEqual(snapshot(dir), before); + }); + + test('the copy\'s own npm test skips these tests once it is set up', () => { + // Without NODE_TEST_CONTEXT the child runs as a top-level test run that prints its own report. + const childEnv = { ...env }; + delete childEnv.NODE_TEST_CONTEXT; + const testFile = join(dir, 'tests', 'setup.test.mjs'); + const result = spawnSync(process.execPath, ['--test', '--test-reporter=tap', testFile], { + cwd: dir, + env: childEnv, + encoding: 'utf8', + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.equal(result.stdout.match(/# SKIP already set up as x_1234_ci/g)?.length, 3, result.stdout); + assert.match(result.stdout, /^# fail 0$/m); + }); + + test('a second run with --force sets up a new app with another scopeId', () => { + const oldScopeId = readJson(dir, 'now.config.json').scopeId; + const forced = setup(dir, '--scope', 'x_1234_other', '--name', 'Other App', '--force'); + assert.equal(forced.status, 0, forced.stderr); + const config = readJson(dir, 'now.config.json'); + assert.equal(config.scope, 'x_1234_other'); + assert.notEqual(config.scopeId, oldScopeId); + assert.match(readFileSync(join(dir, 'src/client/components/App.jsx'), 'utf8'), /

Other App<\/h1>/); + }); +}); + +describe('setup.mjs refusals', { skip: SKIP }, () => { + const dir = makeCopy({ withGit: true }); + const before = snapshot(dir); + + const unsafe = [ + ['a scope with uppercase letters', ['--scope', 'x_1234_MyApp', '--name', 'My App'], /must match/], + ['a scope without the x_ or sn_ prefix', ['--scope', 'u_1234_app', '--name', 'My App'], /must match/], + ['a scope with a path separator', ['--scope', 'x_1234/../app', '--name', 'My App'], /must match/], + ['a scope longer than 18 characters', ['--scope', 'x_1234_much_too_long', '--name', 'My App'], /longer than 18/], + ['a name with a double quote', ['--scope', 'x_1234_app', '--name', 'My "App"'], /may contain/], + ['a name with a single quote', ['--scope', 'x_1234_app', '--name', "Bob's App"], /may contain/], + ['a name with a backtick', ['--scope', 'x_1234_app', '--name', 'My `App`'], /may contain/], + ['a name with HTML', ['--scope', 'x_1234_app', '--name', '