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/.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 cf805b6..94e3ff8 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 @@ -54,14 +55,20 @@ 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(...)`; plain `const` declarations, string concatenation in properties and other statements fail the build. Use one string literal per property. -5. **Script Includes load independently.** Each `.server.js` file defines its own constants (`var FP = 'x_snc_example_';`); - globals from one Script Include are not visible in another. Call them by API name: `new x_snc_example.AppService()`. +5. **Script Includes keep their constants private.** Wrap each `.server.js` file in a function that returns the class, + so the class is the only name the file declares (see `AppService.server.js`): + `var AppService = (function() { var FP = 'x_snc_example_'; … return AppService; })();`. + Top-level `var` constants may be shared with other Script Includes that run in the same transaction, so a second + file declaring `FP` or `MAX_NAME` could change this one's values. Each file defines the constants it needs; call + other Script Includes by API name: `new x_snc_example.AppService()`. 6. **Data access goes through `GlideRecordSecure`** in anything reachable from REST or the UI, so ACLs apply to the caller. Plain `GlideRecord` bypasses ACLs; use it only where elevated access is intended and say why in a comment. 7. **REST routes stay thin** and delegate to `AppService`, which validates input and returns `{ status, body }`. @@ -83,7 +90,7 @@ for the SDK (`now-sdk`, from github.com/ServiceNow/sdk) teaches the same workflo four record ACLs, `AppService` methods, REST routes, an ATF test, then register the file in `src/fluent/index.now.ts`. Use the object form `actions: { read: true }` — it controls *cross-scope* access, so grant writes only when another scope needs them. Keep `allowWebServiceAccess: true` if the REST Table API must reach the table. -- **Add a Script Include:** code in `src/server/.server.js` (`Class.create()` pattern), declaration in +- **Add a Script Include:** code in `src/server/.server.js` (`Class.create()` inside a function wrapper, rule 5), declaration in `src/fluent/script-includes/` with `script: Now.include('../../server/.server.js')`, `apiName: 'x_snc_example.'`, `accessibleFrom: 'package_private'` unless other scopes call it. - **Add a REST route:** add to the `routes` of `src/fluent/rest-api/app-api.now.ts` with its own `$id`, `method`, `path` @@ -104,7 +111,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 4d2daa3..d3e15a8 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. @@ -18,6 +23,21 @@ 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. +- The item list no longer shows a row twice (duplicate React keys) when rows are created between two "Load more" + requests, and a create, update or delete no longer drops the pages loaded with "Load more": pages are merged by + `sys_id` and every loaded page is reloaded after a change (`src/client/services/pagination.js`, unit-tested). + `AppService.listItems` breaks `sys_created_on` ties by `sys_id`, so rows created in the same second keep one order + across pages. Built and unit-tested; not yet exercised on an instance. +- `AppService.server.js` no longer declares its constants (`FP`, `CATEGORIES`, `MAX_NAME`, …) at the top level of the + scope, where another Script Include declaring the same names could change AppService's validation. The file is now + wrapped in a function that returns the class; `AGENTS.md` rule 5 and `TROUBLESHOOTING.md` teach the same pattern. + Behaviour is unchanged (a vm comparison of the old and new file gave identical results); `tests/app-service.test.mjs` + checks that only `AppService` is declared. Not yet run on an instance. + ## [1.0.0] - 2026-10-10 > Brazil-ready, agent-neutral, verified on an instance. 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/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index b758288..a571f81 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 @@ -58,7 +59,8 @@ so every platform script API it calls must be declared. Add the API named in the **If the call is still refused after deploying:** the denied call made the platform create its own privilege record with status *requested*, and installing the app keeps that record instead of your *allowed* one. Approve it (System Applications → Application Cross-Scope Access, set Status to Allowed) or redeploy with `--reinstall` on a development -instance. +instance. `--reinstall` deletes the records in the app's tables; coding agents must get your explicit approval before +they run it (AGENTS.md rule 2). Prefer to find these before deploying to a strict instance: run the app's flows and the ATF suite with `runtimeAccessTracking: "permissive"` and check the log for `Security restricted: Execute operation on API '…' … was @@ -82,5 +84,6 @@ granted` — but note that permissive recording on Brazil Patch 0 missed some AP ## "AppService is not defined" at runtime -Script Includes are called by API name: `new x_snc_example.AppService()`. Each Script Include file must define its own -constants (`var FP = 'x_snc_example_';`); globals from one are not visible in another. +Script Includes are called by API name: `new x_snc_example.AppService()`. The file must still declare the class under +the Script Include's name: `var AppService = (function() { … return AppService; })();`. Each Script Include file +defines its own constants inside that function (`var FP = 'x_snc_example_';`); never rely on another file's constants. 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..dedba1d 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/src/client/components/App.jsx b/src/client/components/App.jsx index 1620288..e0d731d 100644 --- a/src/client/components/App.jsx +++ b/src/client/components/App.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { ApiService, PAGE_SIZE } from '../services/ApiService.js'; +import { loadPages, mergeById } from '../services/pagination.js'; import { ItemForm } from './ItemForm.jsx'; import { ItemTable } from './ItemTable.jsx'; import './App.css'; @@ -10,35 +11,55 @@ export function App() { const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(''); const [version, setVersion] = React.useState(0); + // Pages the user has loaded with "Load more"; a reload after a change keeps all of them. + const pageCount = React.useRef(1); + // Bumped by every reload: a "Load more" answer that a reload overtook is dropped. + const generation = React.useRef(0); + const reloading = React.useRef(false); + const loadingMore = React.useRef(false); - // (Re)load the first page whenever `version` changes. + // (Re)load every loaded page whenever `version` changes (first render, and after each create, update or delete). React.useEffect(() => { let cancelled = false; - ApiService.listItems(0) - .then((page) => { + const run = (generation.current += 1); + reloading.current = true; + loadPages(ApiService.listItems, pageCount.current, PAGE_SIZE) + .then(({ rows, hasMore: more }) => { if (cancelled) return; - setItems(page); - setHasMore(page.length === PAGE_SIZE); + setItems(rows); + setHasMore(more); setError(''); }) .catch((err) => !cancelled && setError(err.message)) - .finally(() => !cancelled && setLoading(false)); + .finally(() => { + if (generation.current === run) reloading.current = false; + if (!cancelled) setLoading(false); + }); return () => { cancelled = true; }; }, [version]); async function loadMore() { + // One request at a time, and none while a reload is replacing the list. + if (reloading.current || loadingMore.current) return; + loadingMore.current = true; + const startedIn = generation.current; try { const page = await ApiService.listItems(items.length); - setItems((current) => [...current, ...page]); + if (generation.current !== startedIn) return; + pageCount.current += 1; + // Rows created since the last request shift the offsets: merge by sys_id so none is listed twice. + setItems((current) => mergeById(current, page)); setHasMore(page.length === PAGE_SIZE); } catch (err) { setError(err.message); + } finally { + loadingMore.current = false; } } - // Runs an API call, shows its error, and reloads the list. Returns true on success. + // Runs an API call, shows its error, and reloads the loaded pages. Returns true on success. async function run(action) { try { await action(); diff --git a/src/client/services/pagination.js b/src/client/services/pagination.js new file mode 100644 index 0000000..99a51f7 --- /dev/null +++ b/src/client/services/pagination.js @@ -0,0 +1,47 @@ +/** + * Offset pagination helpers for the item list (newest first). + * + * Offsets shift when rows are created or deleted between two page requests, so a page can repeat rows that are + * already shown. Pages are therefore merged by sys_id: a repeated row replaces the shown one in place (it is the + * newer copy) and is never listed twice. A row deleted by someone else between two requests can shift a row past + * the window until the next reload; reloading the loaded window after every change keeps that gap short. + */ + +/** + * @param {Array<{sys_id: string}>} current rows already shown, in display order + * @param {Array<{sys_id: string}>} page rows from the next request + * @returns {Array<{sys_id: string}>} current followed by the page's new rows, one entry per sys_id + */ +export function mergeById(current, page) { + const merged = current.slice(); + const index = new Map(merged.map((item, position) => [item.sys_id, position])); + for (const item of page) { + if (index.has(item.sys_id)) { + merged[index.get(item.sys_id)] = item; + } else { + index.set(item.sys_id, merged.length); + merged.push(item); + } + } + return merged; +} + +/** + * Loads `pageCount` pages from the top of the list, merged by sys_id. Stops early at a short page. + * + * @param {(offset: number) => Promise>} fetchPage returns up to pageSize rows from offset + * @param {number} pageCount pages to load (at least 1) + * @param {number} pageSize rows per full page + * @returns {Promise<{rows: Array<{sys_id: string}>, hasMore: boolean}>} hasMore is true when the last page was full + */ +export async function loadPages(fetchPage, pageCount, pageSize) { + let rows = []; + let hasMore = false; + for (let page = 0; page < Math.max(1, pageCount); page += 1) { + const batch = await fetchPage(page * pageSize); + rows = mergeById(rows, batch); + hasMore = batch.length === pageSize; + if (!hasMore) break; + } + return { rows, hasMore }; +} diff --git a/src/server/AppService.server.js b/src/server/AppService.server.js index 29a49b9..611238a 100644 --- a/src/server/AppService.server.js +++ b/src/server/AppService.server.js @@ -4,170 +4,177 @@ * Every query uses GlideRecordSecure, so the table ACLs apply to whoever calls the * REST API. Methods return { status, body }; send() writes that to a REST response. * - * Script Includes load independently: keep the prefix constants in every file. + * The function wrapper keeps the constants private: only AppService is declared in the + * scope, so another Script Include's FP or MAX_NAME can never replace these values. */ -var FP = 'x_snc_example_'; -var ITEM_TABLE = FP + 'item'; -var CATEGORIES = ['general', 'important', 'urgent']; -var MAX_NAME = 200; -var MAX_DESCRIPTION = 4000; -var DEFAULT_LIMIT = 50; -var MAX_LIMIT = 100; -var MAX_OFFSET = 100000; - -var AppService = Class.create(); -AppService.prototype = { - initialize: function() {}, - - /** - * @param {Object} [queryParams] REST query params (values are arrays): limit (1-100), offset (0-100000) - * @returns {{status: number, body: Array}} items, newest first; each says whether the caller may edit or delete it - */ - listItems: function(queryParams) { - var params = queryParams || {}; - var limit = this._clamp(this._firstInt(params.limit, DEFAULT_LIMIT), 1, MAX_LIMIT); - var offset = this._clamp(this._firstInt(params.offset, 0), 0, MAX_OFFSET); - var items = []; - var gr = new GlideRecordSecure(ITEM_TABLE); - gr.orderByDesc('sys_created_on'); - gr.chooseWindow(offset, offset + limit); - gr.query(); - while (gr.next()) { - items.push(this._toItem(gr)); - } - return this._result(200, items); - }, - - /** - * @param {Object} data { name, description?, category?, priority? } - * @returns {{status: number, body: Object}} 201 with the item, 400, or 403 - */ - createItem: function(data) { - var error = this._validate(data, true); - if (error) return this._error(400, error); - var gr = new GlideRecordSecure(ITEM_TABLE); - if (!gr.canCreate()) return this._error(403, 'Not allowed to create items'); - gr.initialize(); - this._apply(gr, data); - if (!gr.insert()) return this._error(403, 'Item was not created'); - return this._result(201, this._toItem(gr)); - }, - - /** - * @param {string} sysId - * @param {Object} data any of { name, description, category, priority } - * @returns {{status: number, body: Object}} 200 with the item, 400, 403, or 404 - */ - updateItem: function(sysId, data) { - var error = this._validate(data, false); - if (error) return this._error(400, error); - var gr = new GlideRecordSecure(ITEM_TABLE); - if (!gr.get(sysId)) return this._error(404, 'Item not found'); - if (!gr.canWrite()) return this._error(403, 'Not allowed to update this item'); - this._apply(gr, data); - if (!gr.update()) return this._error(403, 'Item was not updated'); - return this._result(200, this._toItem(gr)); - }, - - /** - * @param {string} sysId - * @returns {{status: number, body: (Object|undefined)}} 204, 403, or 404 - */ - deleteItem: function(sysId) { - var gr = new GlideRecordSecure(ITEM_TABLE); - if (!gr.get(sysId)) return this._error(404, 'Item not found'); - if (!gr.canDelete()) return this._error(403, 'Not allowed to delete this item'); - if (!gr.deleteRecord()) return this._error(403, 'Item was not deleted'); - return this._result(204); - }, - - /** Reads a JSON request body; returns null when the body is missing or invalid. */ - readBody: function(request) { - try { - var data = request.body.data; - return data && typeof data === 'object' ? data : null; - } catch (e) { - return null; - } - }, - - /** Writes a { status, body } result to a Scripted REST response. */ - send: function(response, result) { - response.setStatus(result.status); - if (result.body !== undefined) response.setBody(result.body); - }, - - /** Returns an error message, or '' when the payload is valid. */ - _validate: function(data, isCreate) { - if (!data || typeof data !== 'object') return 'Request body must be a JSON object'; - if (isCreate || this._has(data, 'name')) { - if (typeof data.name !== 'string' || !data.name.trim()) return 'name is required'; - if (data.name.length > MAX_NAME) return 'name must be at most ' + MAX_NAME + ' characters'; - } - if (this._has(data, 'description')) { - if (typeof data.description !== 'string') return 'description must be a string'; - if (data.description.length > MAX_DESCRIPTION) { - return 'description must be at most ' + MAX_DESCRIPTION + ' characters'; +var AppService = (function() { + var FP = 'x_snc_example_'; + var ITEM_TABLE = FP + 'item'; + var CATEGORIES = ['general', 'important', 'urgent']; + var MAX_NAME = 200; + var MAX_DESCRIPTION = 4000; + var DEFAULT_LIMIT = 50; + var MAX_LIMIT = 100; + var MAX_OFFSET = 100000; + + var AppService = Class.create(); + AppService.prototype = { + initialize: function() {}, + + /** + * @param {Object} [queryParams] REST query params (values are arrays): limit (1-100), offset (0-100000) + * @returns {{status: number, body: Array}} items, newest first; each says whether the caller may edit or delete it + */ + listItems: function(queryParams) { + var params = queryParams || {}; + var limit = this._clamp(this._firstInt(params.limit, DEFAULT_LIMIT), 1, MAX_LIMIT); + var offset = this._clamp(this._firstInt(params.offset, 0), 0, MAX_OFFSET); + var items = []; + var gr = new GlideRecordSecure(ITEM_TABLE); + gr.orderByDesc('sys_created_on'); + // Rows created in the same second need a fixed order, or offset pages could repeat or skip them. + gr.orderByDesc('sys_id'); + gr.chooseWindow(offset, offset + limit); + gr.query(); + while (gr.next()) { + items.push(this._toItem(gr)); } - } - if (this._has(data, 'category') && CATEGORIES.indexOf(data.category) === -1) { - return 'category must be one of: ' + CATEGORIES.join(', '); - } - if (this._has(data, 'priority') && data.priority !== null && !this._isPriority(data.priority)) { - return 'priority must be an integer from 1 to 5'; - } - return ''; - }, - - /** Copies only the fields present in data; the before-insert rule fills an empty priority. */ - _apply: function(gr, data) { - if (this._has(data, 'name')) gr.setValue(FP + 'name', data.name.trim()); - if (this._has(data, 'description')) gr.setValue(FP + 'description', data.description); - if (this._has(data, 'category')) gr.setValue(FP + 'category', data.category); - if (this._has(data, 'priority')) { - gr.setValue(FP + 'priority', data.priority === null ? '' : Number(data.priority)); - } - }, - - _toItem: function(gr) { - return { - sys_id: gr.getUniqueValue(), - name: gr.getValue(FP + 'name') || '', - description: gr.getValue(FP + 'description') || '', - category: gr.getValue(FP + 'category') || 'general', - priority: parseInt(gr.getValue(FP + 'priority'), 10) || null, - can_write: gr.canWrite(), - can_delete: gr.canDelete(), - }; - }, - - /** Accepts 1-5 as a JSON number or a one-digit string; rejects true, [3], '0x3', ' 2 '. */ - _isPriority: function(value) { - if (typeof value === 'number') return value % 1 === 0 && value >= 1 && value <= 5; - return typeof value === 'string' && /^[1-5]$/.test(value); - }, - - _has: function(data, key) { - return Object.prototype.hasOwnProperty.call(data, key); - }, - - _firstInt: function(values, fallback) { - var raw = values && values.length ? values[0] : values; - var parsed = parseInt(raw, 10); - return isNaN(parsed) ? fallback : parsed; - }, - - _clamp: function(value, min, max) { - return Math.min(Math.max(value, min), max); - }, - - _result: function(status, body) { - return { status: status, body: body }; - }, - - _error: function(status, message) { - return this._result(status, { error: { message: message } }); - }, - - type: 'AppService' -}; + return this._result(200, items); + }, + + /** + * @param {Object} data { name, description?, category?, priority? } + * @returns {{status: number, body: Object}} 201 with the item, 400, or 403 + */ + createItem: function(data) { + var error = this._validate(data, true); + if (error) return this._error(400, error); + var gr = new GlideRecordSecure(ITEM_TABLE); + if (!gr.canCreate()) return this._error(403, 'Not allowed to create items'); + gr.initialize(); + this._apply(gr, data); + if (!gr.insert()) return this._error(403, 'Item was not created'); + return this._result(201, this._toItem(gr)); + }, + + /** + * @param {string} sysId + * @param {Object} data any of { name, description, category, priority } + * @returns {{status: number, body: Object}} 200 with the item, 400, 403, or 404 + */ + updateItem: function(sysId, data) { + var error = this._validate(data, false); + if (error) return this._error(400, error); + var gr = new GlideRecordSecure(ITEM_TABLE); + if (!gr.get(sysId)) return this._error(404, 'Item not found'); + if (!gr.canWrite()) return this._error(403, 'Not allowed to update this item'); + this._apply(gr, data); + if (!gr.update()) return this._error(403, 'Item was not updated'); + return this._result(200, this._toItem(gr)); + }, + + /** + * @param {string} sysId + * @returns {{status: number, body: (Object|undefined)}} 204, 403, or 404 + */ + deleteItem: function(sysId) { + var gr = new GlideRecordSecure(ITEM_TABLE); + if (!gr.get(sysId)) return this._error(404, 'Item not found'); + if (!gr.canDelete()) return this._error(403, 'Not allowed to delete this item'); + if (!gr.deleteRecord()) return this._error(403, 'Item was not deleted'); + return this._result(204); + }, + + /** Reads a JSON request body; returns null when the body is missing or invalid. */ + readBody: function(request) { + try { + var data = request.body.data; + return data && typeof data === 'object' ? data : null; + } catch (e) { + return null; + } + }, + + /** Writes a { status, body } result to a Scripted REST response. */ + send: function(response, result) { + response.setStatus(result.status); + if (result.body !== undefined) response.setBody(result.body); + }, + + /** Returns an error message, or '' when the payload is valid. */ + _validate: function(data, isCreate) { + if (!data || typeof data !== 'object') return 'Request body must be a JSON object'; + if (isCreate || this._has(data, 'name')) { + if (typeof data.name !== 'string' || !data.name.trim()) return 'name is required'; + if (data.name.length > MAX_NAME) return 'name must be at most ' + MAX_NAME + ' characters'; + } + if (this._has(data, 'description')) { + if (typeof data.description !== 'string') return 'description must be a string'; + if (data.description.length > MAX_DESCRIPTION) { + return 'description must be at most ' + MAX_DESCRIPTION + ' characters'; + } + } + if (this._has(data, 'category') && CATEGORIES.indexOf(data.category) === -1) { + return 'category must be one of: ' + CATEGORIES.join(', '); + } + if (this._has(data, 'priority') && data.priority !== null && !this._isPriority(data.priority)) { + return 'priority must be an integer from 1 to 5'; + } + return ''; + }, + + /** Copies only the fields present in data; the before-insert rule fills an empty priority. */ + _apply: function(gr, data) { + if (this._has(data, 'name')) gr.setValue(FP + 'name', data.name.trim()); + if (this._has(data, 'description')) gr.setValue(FP + 'description', data.description); + if (this._has(data, 'category')) gr.setValue(FP + 'category', data.category); + if (this._has(data, 'priority')) { + gr.setValue(FP + 'priority', data.priority === null ? '' : Number(data.priority)); + } + }, + + _toItem: function(gr) { + return { + sys_id: gr.getUniqueValue(), + name: gr.getValue(FP + 'name') || '', + description: gr.getValue(FP + 'description') || '', + category: gr.getValue(FP + 'category') || 'general', + priority: parseInt(gr.getValue(FP + 'priority'), 10) || null, + can_write: gr.canWrite(), + can_delete: gr.canDelete(), + }; + }, + + /** Accepts 1-5 as a JSON number or a one-digit string; rejects true, [3], '0x3', ' 2 '. */ + _isPriority: function(value) { + if (typeof value === 'number') return value % 1 === 0 && value >= 1 && value <= 5; + return typeof value === 'string' && /^[1-5]$/.test(value); + }, + + _has: function(data, key) { + return Object.prototype.hasOwnProperty.call(data, key); + }, + + _firstInt: function(values, fallback) { + var raw = values && values.length ? values[0] : values; + var parsed = parseInt(raw, 10); + return isNaN(parsed) ? fallback : parsed; + }, + + _clamp: function(value, min, max) { + return Math.min(Math.max(value, min), max); + }, + + _result: function(status, body) { + return { status: status, body: body }; + }, + + _error: function(status, message) { + return this._result(status, { error: { message: message } }); + }, + + type: 'AppService' + }; + + return AppService; +})(); diff --git a/tests/app-service.test.mjs b/tests/app-service.test.mjs new file mode 100644 index 0000000..30fe23e --- /dev/null +++ b/tests/app-service.test.mjs @@ -0,0 +1,76 @@ +/** + * Tests for src/server/AppService.server.js, run in a Node vm with stubbed Glide APIs + * (the instance-side behaviour is covered by the ATF suite in src/fluent/tests/). + * + * npm test + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, test } from 'node:test'; +import vm from 'node:vm'; + +const SOURCE = readFileSync(new URL('../src/server/AppService.server.js', import.meta.url), 'utf8'); + +/** In-memory GlideRecordSecure that records the calls listItems makes. */ +function stubGlide(calls) { + return function GlideRecordSecure(table) { + calls.push(['table', table]); + this.orderByDesc = (field) => calls.push(['orderByDesc', field]); + this.chooseWindow = (first, last) => calls.push(['chooseWindow', first, last]); + this.query = () => calls.push(['query']); + this.next = () => false; + }; +} + +/** A script context like a scoped app's: Prototype-style Class.create and the platform globals. */ +function scope(calls = []) { + return vm.createContext({ + Class: { + create: () => + function () { + this.initialize.apply(this, arguments); + }, + }, + GlideRecordSecure: stubGlide(calls), + }); +} + +const STUBS = ['Class', 'GlideRecordSecure']; +const declared = (context) => Object.keys(context).filter((key) => !STUBS.includes(key)); + +describe('AppService script include', () => { + test('declares only AppService in the scope', () => { + const context = scope(); + vm.runInContext(SOURCE, context); + assert.deepEqual(declared(context), ['AppService']); + assert.equal(typeof context.AppService, 'function'); + assert.equal(vm.runInContext('new AppService().type', context), 'AppService'); + }); + + test('keeps its own constants when another Script Include declares the same names', () => { + const context = scope(); + vm.runInContext("var FP = 'x_other_'; var MAX_NAME = 1; var CATEGORIES = ['other'];", context); + vm.runInContext(SOURCE, context); + vm.runInContext("var MAX_NAME = 2; var CATEGORIES = ['later']; var MAX_LIMIT = 1;", context); + const svc = vm.runInContext('new AppService()', context); + assert.equal(svc._validate({ name: 'x'.repeat(200), category: 'urgent' }, true), ''); + assert.equal(svc._validate({ name: 'x'.repeat(201) }, true), 'name must be at most 200 characters'); + assert.equal(svc._validate({ name: 'a', category: 'other' }, true), 'category must be one of: general, important, urgent'); + }); + + test('lists newest first with a stable tie-break and a clamped window', () => { + const calls = []; + const context = scope(calls); + vm.runInContext(SOURCE, context); + const result = vm.runInContext('new AppService()', context).listItems({ limit: ['500'], offset: ['20'] }); + // JSON round trip: objects made inside the vm have that context's prototypes. + assert.deepEqual(JSON.parse(JSON.stringify(result)), { status: 200, body: [] }); + assert.deepEqual(calls, [ + ['table', 'x_snc_example_item'], + ['orderByDesc', 'sys_created_on'], + ['orderByDesc', 'sys_id'], + ['chooseWindow', 20, 120], + ['query'], + ]); + }); +}); diff --git a/tests/pagination.test.mjs b/tests/pagination.test.mjs new file mode 100644 index 0000000..ded9635 --- /dev/null +++ b/tests/pagination.test.mjs @@ -0,0 +1,102 @@ +/** + * Tests for the item list's offset pagination (src/client/services/pagination.js). + * + * npm test + */ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { loadPages, mergeById } from '../src/client/services/pagination.js'; + +const PAGE_SIZE = 3; + +/** A newest-first list served with limit/offset, like GET /items. */ +function fakeServer(count) { + let next = count; + const rows = Array.from({ length: count }, (_, i) => ({ sys_id: `id${count - i}`, name: `item ${count - i}` })); + return { + rows, + fetchPage: async (offset) => rows.slice(offset, offset + PAGE_SIZE).map((row) => ({ ...row })), + create() { + next += 1; + rows.unshift({ sys_id: `id${next}`, name: `item ${next}` }); + }, + rename(sysId, name) { + rows.find((row) => row.sys_id === sysId).name = name; + }, + remove(sysId) { + rows.splice(rows.findIndex((row) => row.sys_id === sysId), 1); + }, + }; +} + +const ids = (rows) => rows.map((row) => row.sys_id); + +describe('mergeById', () => { + test('appends new rows in order', () => { + assert.deepEqual(ids(mergeById([{ sys_id: 'a' }], [{ sys_id: 'b' }, { sys_id: 'c' }])), ['a', 'b', 'c']); + }); + + test('lists a repeated row once, in its first place, with the newer copy', () => { + const merged = mergeById( + [{ sys_id: 'a', name: 'old' }, { sys_id: 'b' }], + [{ sys_id: 'a', name: 'new' }, { sys_id: 'c' }, { sys_id: 'c' }], + ); + assert.deepEqual(ids(merged), ['a', 'b', 'c']); + assert.equal(merged[0].name, 'new'); + }); + + test('does not change its inputs', () => { + const current = [{ sys_id: 'a' }]; + mergeById(current, [{ sys_id: 'b' }]); + assert.deepEqual(current, [{ sys_id: 'a' }]); + }); +}); + +describe('loadPages', () => { + test('loads the requested number of pages and reports more', async () => { + const server = fakeServer(10); + const { rows, hasMore } = await loadPages(server.fetchPage, 2, PAGE_SIZE); + assert.deepEqual(ids(rows), ['id10', 'id9', 'id8', 'id7', 'id6', 'id5']); + assert.equal(hasMore, true); + }); + + test('stops at a short page', async () => { + const server = fakeServer(4); + const { rows, hasMore } = await loadPages(server.fetchPage, 5, PAGE_SIZE); + assert.deepEqual(ids(rows), ['id4', 'id3', 'id2', 'id1']); + assert.equal(hasMore, false); + }); + + test('loads at least one page', async () => { + const { rows } = await loadPages(fakeServer(2).fetchPage, 0, PAGE_SIZE); + assert.equal(rows.length, 2); + }); +}); + +describe('the App.jsx flow', () => { + test('"Load more" after someone else creates a row lists no row twice and skips none', async () => { + const server = fakeServer(7); + let { rows } = await loadPages(server.fetchPage, 1, PAGE_SIZE); + server.create(); // id8 lands on top and shifts every offset by one + rows = mergeById(rows, await server.fetchPage(rows.length)); + rows = mergeById(rows, await server.fetchPage(rows.length)); + rows = mergeById(rows, await server.fetchPage(rows.length)); + assert.equal(new Set(ids(rows)).size, rows.length, 'no duplicate sys_id (React key)'); + for (let n = 1; n <= 7; n += 1) assert.ok(ids(rows).includes(`id${n}`), `id${n} is shown`); + }); + + test('a reload after a change keeps every loaded page and shows the change', async () => { + const server = fakeServer(10); + let pageCount = 1; + const { rows: first } = await loadPages(server.fetchPage, pageCount, PAGE_SIZE); + const shown = mergeById(first, await server.fetchPage(first.length)); + pageCount += 1; + assert.equal(shown.length, 6); + server.rename('id5', 'renamed'); + server.remove('id9'); + server.create(); + const { rows } = await loadPages(server.fetchPage, pageCount, PAGE_SIZE); + assert.deepEqual(ids(rows), ['id11', 'id10', 'id8', 'id7', 'id6', 'id5']); + assert.equal(rows.find((row) => row.sys_id === 'id5').name, 'renamed'); + }); +}); diff --git a/tests/setup.test.mjs b/tests/setup.test.mjs new file mode 100644 index 0000000..02ea06b --- /dev/null +++ b/tests/setup.test.mjs @@ -0,0 +1,272 @@ +/** + * 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. +// No global git config or hooks: the temp repos must not run the user's hooks or templates. +const env = { ...process.env, GIT_CEILING_DIRECTORIES: scratch, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' }; + +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', '