Skip to content

Commit 54521f0

Browse files
qa(checklist): a hook-refused script action shows exactly one error toast carrying its sentence — item, showcase fixture, and one run (#21609)
Fixes #21596 Clause-②: no ## What this does Gives objectui#9151's re-observation a home: one platform-checklist item, the smallest showcase fixture it needs, and one recorded run of it against a console built at this repo's `.objectui-sha`. **Run verdict: PASS** (1/1 item, 4/4 clauses, reproduced on two finished tasks on fresh page loads). The console showed exactly one `error` toast carrying the hook's sentence, tied to a `400` from the action route, and the record was left unchanged. ## The fixture (measured absent, so added) `examples/app-showcase` had no hook that refuses a write. `src/data/hooks/index.ts` held four hooks: trim, stamp, and two log-only after-hooks, none of which throw. Added: - `showcase_guard_task_reopen` (`src/data/hooks/index.ts`, `GuardTaskReopenHook`): a `beforeUpdate` hook on `showcase_task`. Its condition is `previous.done == true && record.done != true` and its sandboxed body throws `TASK_REOPEN_REFUSAL` = "A finished task cannot be reopened. Create a follow-up task instead." with `onError: 'abort'`. - `showcase_reopen_task` (`src/ui/actions/index.ts`, `ReopenTaskAction`): a `script` action, `perRecord`, offered only on finished tasks. It appears in the task list's row menu only (`list_item`). The Task Detail page is `kind: 'full'` and draws no `record_header` bar, so declaring that location would have been inert. Its body writes `{ id, done: false }` through `ctx.api`. - zh-CN label for the action (`check:i18n-coverage` ratchet). - `test/task-reopen-hook-refusal.test.ts` (5 tests). It uses the production harness: real `ObjectQL`, `SqlDriver` and `QuickJSScriptRunner`, with the app's real objects and hooks. The tests check four things. The reopening write is refused with the sentence and the row is unchanged. An edit of a finished task that leaves `done` alone still lands. With the hooks unbound, the same write lands (reverse check). The action body writes exactly the refused shape. ## The checklist item `docs/qa/platform-checklist/areas/records-forms.json`, id `records-forms.script-action-hook-refusal-toast` (revision 1, P2, browser). It sits in records-forms, beside the area's action-surface items (`action-location-matrix`, `action-param-widgets`, `object-hook-lifecycle`). It has four clauses: - **network:** the action route answers 4xx carrying the sentence. - **dom:** exactly one new toast, `data-type=error`, containing the sentence. It is counted inside sonner's 4s auto-dismiss window, excluding `data-removed` nodes. - **network:** the toast is tied to its request. That request is the only non-GET request in the window, and its message matches the toast. - **api:** the record is unchanged. It is mapped under `action` and `hook` in `coverage.json`. `records-forms.object-hook-lifecycle` was bumped from revision 2 to 3, roster only, with no clause moved. Two of its knownGaps became false once the fifth hook landed: "no two showcase hooks share an object+event" and "the fixtures never throw". Both now name the new hook and say why it does not close that item's ordering clause or its beforeInsert abort-rollback clause. `pnpm check:platform-checklist` passes with exit 0: 15 areas, 270 items, coverage 39 kinds mapped and 2 waived, symbol anchors 650/660. ## The run - **Durable run record: #21610** (the `qa-run` issue, RUNNER's record of this run). - Record: `docs/qa/platform-checklist/runs/2026-10-03-script-action-hook-refusal-toast.json`. RUNNER's location; git-ignored, so it is not in this diff. The `qa-run` issue body is in the report comment on #21596. - Environment: framework `4a428c11a0` (the tree served), objectui pin `89cad75d55702cc4f267bead5bf267de575d5842`. The console was built by `pnpm objectui:build` (exit 0, stamped objectui@89cad75d5570). The server was an isolated `objectstack dev --ui --seed-admin` on its own port with its own file DB. Driven by Playwright through `/opt/pw-browsers/chromium`, signed in through the form as the seeded admin. - Both attempts returned `POST /api/v1/actions/showcase_task/showcase_reopen_task` with status `400 VALIDATION_ERROR`, message "hook 'showcase_guard_task_reopen' threw: Error: A finished task cannot be reopened. Create a follow-up task instead." - Live toasts went from 0 before the click to 1 after (`data-type=error`, same text). A MutationObserver installed before the click saw exactly one toast node added in the following 6s. - The only non-GET request in the window was that POST. - `done`, `progress` and `updated_at` were identical before and after. ## Acceptance notes - **Not scored against the item:** the action route hands the user the sandbox debug wrapper. The toast reads "hook 'showcase_guard_task_reopen' threw: Error: " followed by the sentence. The same refusal through `PATCH /api/v1/data/showcase_task/ID` answers `400` with the bare sentence. The wrapper is what `packages/qa/dogfood/test/hook-error-format.dogfood.test.ts` says belongs in server logs, not in the toast. This is reported to the PM in the report and is not fixed here: no runtime change is in scope. - **Changeset:** none. The only package this touches is `@objectstack/example-showcase`, which is `"private": true`. Its earlier showcase-only fixes (`393ae878d3`, `99398542b3`) also carried none. `docs/qa/**` does not publish. `skip-changeset` applies. ## Verification (head `ff3d71c7cf`) - `pnpm --filter @objectstack/example-showcase run typecheck` passed (exit 0); `--listFiles` includes the new test. - `pnpm --filter @objectstack/example-showcase exec vitest run --maxWorkers=2`: 32 files and 399 tests passed. - `node scripts/pm/dispatch-gates.mjs --commands` derived 45 commands; all 45 exited 0. `--ran` reconciliation: 45 derived, 45 run, 0 unrun. --- _Generated by [Claude Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0721848 commit 54521f0

6 files changed

Lines changed: 373 additions & 8 deletions

File tree

‎docs/qa/platform-checklist/areas/records-forms.json‎

Lines changed: 104 additions & 6 deletions
Large diffs are not rendered by default.

‎docs/qa/platform-checklist/coverage.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"api-backend.packaged-action-disabled-dispatch",
1010
"api-backend.action-activation-door-contract",
1111
"automation.setup-packaged-automation-board",
12-
"platform-core.activation-ledger-registration-home"
12+
"platform-core.activation-ledger-registration-home",
13+
"records-forms.script-action-hook-refusal-toast"
1314
]
1415
},
1516
"agent": {
@@ -154,7 +155,8 @@
154155
"items": [
155156
"records-forms.object-hook-lifecycle",
156157
"cli.hook-body-extraction-gates",
157-
"access-security.record-view-read-audit"
158+
"access-security.record-view-read-audit",
159+
"records-forms.script-action-hook-refusal-toast"
158160
]
159161
},
160162
"job": {

‎examples/app-showcase/src/data/hooks/index.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,50 @@ export const StampInquiryDefaultsHook = {
119119
description: 'Stamps status=new and source=web on every new inquiry (public web-to-lead defaults).',
120120
};
121121

122+
/**
123+
* The sentence {@link GuardTaskReopenHook} refuses with. Exported so the
124+
* fixture's test quotes the same string the hook throws.
125+
*/
126+
export const TASK_REOPEN_REFUSAL =
127+
'A finished task cannot be reopened. Create a follow-up task instead.';
128+
129+
/**
130+
* beforeUpdate (gated) — the showcase's one hook that REFUSES a write, with a
131+
* sentence addressed to the user.
132+
*
133+
* `throw new Error('<sentence>')` from a sandboxed body is the business-refusal
134+
* shape: the write is aborted (`onError: 'abort'`) and the sentence travels to
135+
* the caller as a 4xx — on `/data`, and through a script action's `ctx.api`
136+
* write on `/actions` too (`packages/runtime/src/sandbox/
137+
* nested-hook-refusal-is-a-rejection.test.ts`). `showcase_reopen_task`
138+
* (`src/ui/actions/index.ts`) is the script action that reaches it, and the
139+
* platform checklist item `records-forms.script-action-hook-refusal-toast`
140+
* drives that action in the console.
141+
*
142+
* The condition is the two-root transition form (same lesson as
143+
* {@link AuditTaskCompletionHook}): only the write that flips `done` from true
144+
* to not-true is refused. An edit of a finished task that leaves `done` alone
145+
* still lands.
146+
*/
147+
export const GuardTaskReopenHook = {
148+
name: 'showcase_guard_task_reopen',
149+
label: 'Guard Task Reopen',
150+
object: 'showcase_task',
151+
events: ['beforeUpdate'] as LifecycleEvent[],
152+
condition: 'previous.done == true && record.done != true',
153+
body: {
154+
language: 'js' as const,
155+
source: `throw new Error(${JSON.stringify(TASK_REOPEN_REFUSAL)});`,
156+
},
157+
priority: 60,
158+
onError: 'abort' as const,
159+
description: 'Refuses reopening a finished task (done true to false) with a user-facing sentence.',
160+
};
161+
122162
export const allHooks = [
123163
NormalizeTaskTitleHook,
124164
StampInquiryDefaultsHook,
165+
GuardTaskReopenHook,
125166
AuditTaskCompletionHook,
126167
WarnOverBudgetHook,
127168
];

‎examples/app-showcase/src/system/translations/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,9 @@ export const ShowcaseTranslationBundle = {
494494
label: '重算所选',
495495
successMessage: '已为整个选中集重算工时。',
496496
},
497+
// The hook-refusal specimen: every click is refused by
498+
// showcase_guard_task_reopen, so it carries no successMessage.
499+
showcase_reopen_task: { label: '重新打开' },
497500
},
498501
// Section headings of the six form-view projections in
499502
// `ui/views/task.view.ts` (edit / tabbed / wizard / split / quick).

‎examples/app-showcase/src/ui/actions/index.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,43 @@ export const MarkDoneAction = defineAction({
8484
refreshAfter: true,
8585
});
8686

87+
/**
88+
* script — the hook-REFUSAL specimen. Reopening a finished task is refused by
89+
* the `showcase_guard_task_reopen` beforeUpdate hook
90+
* (`src/data/hooks/index.ts`), so a click on this action always fails, by
91+
* design: the body's `ctx.api` write is aborted and the hook's sentence is what
92+
* the user must be told. The action route answers it as a 4xx carrying that
93+
* sentence, and the console owes exactly one error toast that contains it.
94+
* Platform checklist item `records-forms.script-action-hook-refusal-toast`
95+
* drives this.
96+
*
97+
* Shown only on finished tasks — the only records the refusal applies to.
98+
*/
99+
export const ReopenTaskAction = defineAction({
100+
name: 'showcase_reopen_task',
101+
label: 'Reopen',
102+
icon: 'rotate-ccw',
103+
objectName: task,
104+
type: 'script',
105+
body: {
106+
language: 'js',
107+
source:
108+
"var id = ctx.recordId || (ctx.record && ctx.record.id) || input.recordId;" +
109+
"if (!id) throw new Error('No record to reopen');" +
110+
"await ctx.api.object('showcase_task').update({ id: id, done: false });" +
111+
"return { ok: true, id: id };",
112+
capabilities: ['api.write'],
113+
},
114+
execution: 'perRecord',
115+
// #8990 — `has()` guards the sparse `list_item` face, as on Mark Done.
116+
visible: 'has(record.done) && record.done == true',
117+
// The task list's row menu only. `record_header` would be inert here: the
118+
// Task Detail page (`showcase_task_detail`, `kind: 'full'`) owns the whole
119+
// record layout and renders no header action bar.
120+
locations: ['list_item'],
121+
refreshAfter: true,
122+
});
123+
87124
/** url — navigate out, from the row overflow menu. */
88125
export const OpenDocsAction = defineAction({
89126
name: 'showcase_open_docs',
@@ -457,6 +494,7 @@ export const PortfolioSnapshotAction = defineAction({
457494

458495
export const allActions = [
459496
MarkDoneAction,
497+
ReopenTaskAction,
460498
OpenDocsAction,
461499
BulkReassignAction,
462500
QuickViewAction,
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The showcase's hook-REFUSAL fixture: `showcase_guard_task_reopen` refuses the
5+
* write that reopens a finished task, with a sentence addressed to the user,
6+
* and `showcase_reopen_task` is the script action whose write reaches it.
7+
*
8+
* Platform checklist item `records-forms.script-action-hook-refusal-toast`
9+
* drives this pair in the console (4xx on the action route, exactly one error
10+
* toast carrying the sentence, record unchanged). This file pins the two halves
11+
* the item stands on, so a run that FAILS is about the console and the action
12+
* route, never about a fixture that quietly stopped refusing:
13+
*
14+
* 1. on the real engine with the app's real hooks, the reopening write is
15+
* refused with the sentence and the stored row is unchanged — while an
16+
* edit of the same finished task that leaves `done` alone still lands;
17+
* 2. the action's body writes exactly the reopening shape (`done: false` on
18+
* its own record), so a click reaches the hook's condition.
19+
*
20+
* The action route's own 4xx for a nested sandboxed refusal is pinned in
21+
* `packages/runtime/src/sandbox/nested-hook-refusal-is-a-rejection.test.ts`;
22+
* it is not restated here.
23+
*
24+
* Harness: the production one `hook-body-persisted-writes.test.ts` uses — real
25+
* `ObjectQL`, real `SqlDriver` (better-sqlite3), real `QuickJSScriptRunner`
26+
* behind `hookBodyRunnerFactory`, the app's real objects and hooks.
27+
*/
28+
29+
import { describe, it, expect, afterEach } from 'vitest';
30+
import { ObjectQL } from '@objectstack/objectql';
31+
import { SqlDriver } from '@objectstack/driver-sql';
32+
import {
33+
QuickJSScriptRunner,
34+
actionBodyRunnerFactory,
35+
hookBodyRunnerFactory,
36+
} from '@objectstack/runtime';
37+
38+
import { Account, Project, Task } from '../src/data/objects/index.js';
39+
import { allHooks, TASK_REOPEN_REFUSAL } from '../src/data/hooks/index.js';
40+
import { ReopenTaskAction } from '../src/ui/actions/index.js';
41+
42+
const APP_ID = 'com.objectstack.showcase';
43+
const PACKAGE_ID = `app:${APP_ID}`;
44+
45+
const openEngines: ObjectQL[] = [];
46+
afterEach(async () => {
47+
while (openEngines.length) {
48+
try { await openEngines.pop()?.destroy(); } catch { /* noop */ }
49+
}
50+
});
51+
52+
async function bootShowcase(hooks: unknown[] = allHooks): Promise<ObjectQL> {
53+
const driver = new SqlDriver({
54+
client: 'better-sqlite3',
55+
connection: { filename: ':memory:' },
56+
useNullAsDefault: true,
57+
});
58+
await driver.connect();
59+
60+
const engine = new ObjectQL();
61+
openEngines.push(engine);
62+
engine.registerDriver(driver as never, true);
63+
await engine.init();
64+
for (const def of [Account, Project, Task]) {
65+
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
66+
}
67+
await engine.syncSchemas();
68+
engine.bindHooks(hooks as never[], {
69+
packageId: PACKAGE_ID,
70+
bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: APP_ID }),
71+
});
72+
return engine;
73+
}
74+
75+
const ctx = { context: { userId: 'u_showcase', isSystem: true } };
76+
77+
const readBack = async (engine: ObjectQL, id: string) =>
78+
(await engine.find('showcase_task', { where: { id } }, ctx as never))[0] as any;
79+
80+
/** A finished task on a real project chain: done = true, progress = 100. */
81+
async function finishedTask(engine: ObjectQL): Promise<string> {
82+
const account: any = await engine.insert('showcase_account', { name: 'Initech', status: 'active' }, ctx as never);
83+
const project: any = await engine.insert(
84+
'showcase_project',
85+
{ name: 'Platform', account: String(account.id), status: 'planned' },
86+
ctx as never,
87+
);
88+
const task: any = await engine.insert(
89+
'showcase_task',
90+
{ title: 'Audit current IA', project: String(project.id), status: 'backlog' },
91+
ctx as never,
92+
);
93+
const id = String(task.id);
94+
await engine.update('showcase_task', { id, done: true, progress: 100 }, ctx as never);
95+
expect((await readBack(engine, id)).done).toBeTruthy();
96+
return id;
97+
}
98+
99+
/** Every string an error carries that a client could be shown. */
100+
function textOf(err: any): string {
101+
return [err?.message, err?.innerMessage, err?.cause?.message].filter(Boolean).join(' | ');
102+
}
103+
104+
describe('showcase_guard_task_reopen — the hook refuses reopening a finished task', () => {
105+
it('the reopening write is refused with the sentence, and the stored row is unchanged', async () => {
106+
const engine = await bootShowcase();
107+
const id = await finishedTask(engine);
108+
109+
const err = await engine
110+
.update('showcase_task', { id, done: false }, ctx as never)
111+
.then(() => null, (e: unknown) => e);
112+
113+
expect(err, 'expected the reopening write to be refused, but it resolved').not.toBeNull();
114+
expect(textOf(err)).toContain(TASK_REOPEN_REFUSAL);
115+
// The refusal is a business sentence, not a script fault: no native
116+
// error-class name leads it (`TypeError: …` is the fault shape #7543 keeps
117+
// off the wire).
118+
expect(String((err as any).innerMessage ?? '')).not.toMatch(/^(TypeError|ReferenceError|SyntaxError):/);
119+
120+
const stored = await readBack(engine, id);
121+
expect(stored.done).toBeTruthy();
122+
expect(stored.progress).toBe(100);
123+
}, 30000);
124+
125+
it('an edit of the finished task that leaves `done` alone still lands', async () => {
126+
// The two-root condition: `previous.done == true && record.done != true`.
127+
// A guard collapsed to `previous.done == true` would refuse this too and
128+
// make every finished task read-only.
129+
const engine = await bootShowcase();
130+
const id = await finishedTask(engine);
131+
132+
await engine.update('showcase_task', { id, priority: 'high' }, ctx as never);
133+
134+
const stored = await readBack(engine, id);
135+
expect(stored.priority).toBe('high');
136+
expect(stored.done).toBeTruthy();
137+
}, 30000);
138+
139+
it('REVERSE: with the hooks unbound the same write lands — the refusal is the hook', async () => {
140+
const engine = await bootShowcase([]);
141+
const id = await finishedTask(engine);
142+
143+
await engine.update('showcase_task', { id, done: false }, ctx as never);
144+
145+
expect((await readBack(engine, id)).done).toBeFalsy();
146+
}, 30000);
147+
});
148+
149+
describe('showcase_reopen_task — the script action writes the shape the hook refuses', () => {
150+
it("the body updates its own record with done: false and nothing else", async () => {
151+
let written: { object: string; data: Record<string, unknown> } | undefined;
152+
const ql = {
153+
object: (object: string) => ({
154+
update: async (data: Record<string, unknown>) => {
155+
written = { object, data };
156+
return { id: data.id };
157+
},
158+
}),
159+
};
160+
161+
const handler = actionBodyRunnerFactory(new QuickJSScriptRunner(), { ql, appId: 'showcase' })(
162+
ReopenTaskAction as never,
163+
);
164+
expect(typeof handler).toBe('function');
165+
166+
await handler!({
167+
recordId: 'task_1',
168+
record: { id: 'task_1', done: true, progress: 100 },
169+
params: {},
170+
user: { id: 'u1' },
171+
});
172+
173+
expect(written).toEqual({ object: 'showcase_task', data: { id: 'task_1', done: false } });
174+
});
175+
176+
it('is offered only on finished tasks', () => {
177+
expect(ReopenTaskAction.type).toBe('script');
178+
expect(ReopenTaskAction.execution).toBe('perRecord');
179+
const visible = ReopenTaskAction.visible as unknown;
180+
const source = typeof visible === 'string' ? visible : (visible as { source?: string }).source;
181+
expect(source).toBe('has(record.done) && record.done == true');
182+
});
183+
});

0 commit comments

Comments
 (0)