Skip to content

Commit 5dcbdb4

Browse files
feat(service-print): print a record on any print page of its object, not only its default record page (#26662)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XN1ALtdy52CrgiAPP8xVRiT)_ Fixes #26583 Clause-②: yes (widening) Metadata-protocol: none **No authorable key is added or changed** (no `packages/spec/**` in the diff; the request body already carries `page`), so per triage 6440154784 this card grades **p2**. ## 维护者速读 - **改了什么**:服务端打印(`@objectstack/service-print`)现在可以把记录打印在该对象的**任意一个声明了 `print` 的记录页**上,不再只限默认记录页。默认记录页照旧走记录路由;其它打印页走控制台已发布的具名记录页路由 `…/record/ID/page/PAGE`。 - **为什么改**:以前要打印报价,就得把报价的日常记录页改成打印文档,销售每天都会失去状态路径、亮点区、详情/相关标签页和动态(objectstack-ai/hotcrm#2312 因此停着)。维护者裁决 A(6439306670)。 - **风险与回滚**:低。只放宽一条服务端检查、换一个 URL;页面不是该对象的记录页、或没声明 `print` 仍然 400 拒绝,未知页名仍然 404。回滚即 revert 本 PR。 - **先看**:`packages/services/service-print/src/print-service.ts`(`refuseUnlessPrintPage`、新的 `isRecordPageOf` 与 `printPageUrl`);`packages/qa/dogfood/test/print-service-named-page.dogfood.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/service-print exec vitest run src/print-service.test.ts`,预期 50/50 通过,其中「not the default record page prints on the console’s named record-page route」一条。 - **需要你做的**:无,席位落地。 ## What changes `PrintService.printRecordToFile` (and so `POST /api/v1/print/records`), step ③ "the page": - **Accepted:** any page that is a record page of the request's object and declares `print`. The third refusal on `main`, "is not the default record page of OBJECT (isDefault: true)", is gone. - **The URL:** the object's default record page still renders on the console's record route, `CONSOLE/apps/APP/OBJECT/record/ID`. Any other print page renders on the console's named record-page route, `CONSOLE/apps/APP/OBJECT/record/ID/page/PAGE` (`RECORD_NAMED_PAGE_ROUTE`, `ui/packages/app-shell/src/console/AppContent.tsx:280`), every segment encoded on its own (`printPageUrl`). - **Two loud refusals remain**, both `VALIDATION_ERROR` / 400, before anything is read, minted or launched: the page is not a record page of the object, or it declares no `print`. An unknown page name stays `RESOURCE_NOT_FOUND` / 404. **The predicate both sides use.** The console's named route renders the named page only when `isRecordPageOf(named, objectName)` holds (`ui/packages/app-shell/src/views/RecordDetailView.tsx:787–792`), and that function is `p.type === 'record' && p.object === objectName` (`ui/packages/react/src/hooks/usePageAssignment.ts:94–98`); any other name gets a not-found state, never a fallback. The server's check is now that exact reading (`isRecordPageOf` in `print-service.ts`, docblock names the console's), so the server prints exactly the set the console draws, no wider. It is a local copy because `service-print` cannot import `@object-ui/react`, and adding a shared reading to `@objectstack/spec` is outside this lane. `content/docs/ui/print-pages.mdx`: the "A print page that is not the default is refused" sentence is rewritten to say a record prints on any print page of its object (and on which route), the example page drops `isDefault: true`, and the refusals table row reads "not a record page of the object (`type: 'record'`, that `object`), or declares no `print`". The repo carries no translation of this page (`find content -name 'print-pages*'` → the en file only). Changeset: `@objectstack/service-print` patch, both declaration lines line-initial. ## Why To print a quote, an app had to make the quote's everyday record page its printed document, and reps lost the status path, highlights, Details/Related tabs and the activity feed on every quote (objectstack-ai/hotcrm#2312, REQ-0008). The console's named record-page route (objectui#12715) already draws a named record page for a record and refuses any other name; only the server half refused. Ruling 6439306670, letter A. ## Tests Red first on `main` (`c739de755c`), then green at head: | Suite | On `main` | At head | |---|---|---| | `service-print` `src/print-service.test.ts` (tests only changed) | 3 failed / 47 passed: the named-route accept, the encoded named URL, the typeless default page | 50 / 50 | | dogfood `print-service-named-page.dogfood.test.ts` + `print-service-archive.dogfood.test.ts` (stand-in browser, real showcase boot) | named-page leg red: "The page 'showcase_project_brief' is not the default record page of 'showcase_project' (isDefault: true)…" | 13 / 13 | | dogfood `print-service-render.dogfood.test.ts` (real console bundle + real Chromium, both placements, `OS_PAGE_REACT_ENABLED=on`, see Acceptance notes) | the 2 named-page legs red with the same refusal; the other 6 green | 8 / 8 | The `main` legs ran with `print-service.ts` restored from `c739de755c` (the dogfood imports the service from `src/`), restored afterwards by `git checkout HEAD --` and proved by `git diff HEAD` empty and the blob hash matching HEAD. Unit rows (`print-service.test.ts`): - `'a print page that is not the default record page'` moved from the refusal table to an accept: it renders `…/record/inv_1/page/invoice_document` and archives `invoice_document-inv_1.pdf`. - The default page still renders the plain record route (pinned), and a name carrying `/` and a record id carrying a space are encoded as one segment each. - Refusal rows kept or added: a page of another object, a page with no `print` (default and non-default), a default `type: 'home'` page, a non-default `type: 'app'` print page, and a page with no `type` (default and non-default). Dogfood (new `print-service-named-page.dogfood.test.ts`, fixture in `print-named-page.fixture.ts`): `showcase_project` keeps its working default record page `showcase_project_detail` (slotted, no `print`); a project brief that declares `print` is saved beside it through the metadata save door (`protocol.saveMetaItem`). A project prints on the brief through the named route and is archived on the project as the requester; the working page (no `print`) and the brief asked for an invoice (another object) are refused 400 with nothing minted. **What the real render put on paper.** A one-off copy of the render leg wrote both PDFs out; `pdftotext -layout`: - the brief, printed on the named route: `Project brief` / `Brief` / `PROJECT NAME STATUS` / `Compliance Audit On Hold` / `START DATE END DATE` / `Oct 12 Nov 26` / `1/1`, so the console drew the brief for that project, not a not-found state and not the default page; - the invoice, on the record route: `ObjectStack Showcase Ltd · 示例有限公司` / `Invoice` / `INV-RENDER-local Draft` …, unchanged. Other local runs at head: `pnpm --filter @objectstack/service-print test` 8 files / 114 tests passed; `pnpm --filter @objectstack/service-print typecheck` exit 0 (its `include` is `src`, so the test files are compiled); `pnpm --filter @objectstack/dogfood typecheck` exit 0; eslint `--no-inline-config` on the touched service-print and dogfood files exit 0. ## Acceptance notes - **`Clause-②` measured on the diff.** The accept set widens: a request naming a non-default print page, refused 400 on `main`, now prints. One shape moves the other way, stated rather than hidden: a page body in the registry with **no `type` at all** that is the default record page and declares `print` was accepted on `main` (the old check read `defaultRecordPageObject`, which treats an absent `type` as `'record'`) and is now refused 400, because the console's `isRecordPageOf` reads `type` exactly. On `main` such a print did not print that page: the console's record route filters with the same `isRecordPageOf`, so it drew the synthesized default page instead. No authoring door produces that body (`defineStack` parses with `PageSchema`'s declared default, and the metadata save door and stored-row hydration fill it, #20101 `withDeclaredPageTypeDefault`); only a plugin calling `registry.registerItem` with a raw body can. I declared `yes (widening)`; if the seat reads that one shape as a narrowing, the arm is the seat's to change. - **The render dogfood is red on a default environment, at `main` too, for a reason outside this card.** Without `OS_PAGE_REACT_ENABLED`, the console's policy is `script-src 'self' 'nonce-…' 'wasm-unsafe-eval'` (no `'unsafe-eval'`, `packages/cli/src/utils/console.ts:851–852`, off unless set), and the print driver's `page.waitForFunction(PRINT_READY_PREDICATE, …)` (`chromium-driver.ts:303`) passes a string, which Chromium refuses to evaluate: all 6 legs failed, including the untouched invoice leg, with `The print engine failed at the local executable …: page.waitForFunction: EvalError: Evaluating a string as JavaScript violates the following Content Security Policy directive …`. CI skips this file (no console bundle), so `main` has not seen it. Reported to the seat as a defect for its own card; the readings above were taken with `OS_PAGE_REACT_ENABLED=on`. - `examples/app-showcase/src/ui/pages/invoice-document.page.ts:12–14` still says the invoice's print page is its default record page "because the console renders a record on its default record page, so that is the page a record prints on". That reason is no longer the only option after this PR (the page may stay the default by choice). It is a comment in a private package, outside the claim's file surface: left for the seat. ## Verification Head `25f758a5e`. Local subset derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff (89 commands), each run with its exit code captured before any pipe, reconciled with `--ran`: ```text ✓ dispatch-gates --ran: 89 derived famil(ies) accounted for — 88 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3). ``` - 88 exit 0. Two of them first exited 3 on their prerequisite and were re-run after meeting it: `check-plugin-teardown-shape.mjs --self-test` (positive control `621a4876` fetched into the shallow clone; then `48 cases pass`), and `spec check:skill-examples` (after building `@objectstack/client-react`; then `266 prose examples type-check`). - **NOT MEASURED: `pnpm check:dual-build-cjs-loads`**, reason: it needs every package's `dist` (`no packages/plugins/knowledge-memory/dist` …) and this container built only the dogfood closure. CI runs it. - Not local, CI's: `pnpm lint` repo-wide (eslint on the touched files: exit 0), `test:repo` (the diff touches no `vitest.repo-tests.json` project), the full dogfood battery (no `!` and no `(narrowing)` arm). <details><summary>Every local command and its exit code</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/check-tenant-audit-census.mjs :: exit 0 node scripts/check-tenant-audit-census.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 3 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint passes on the touched files (repo-wide `pnpm lint` is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a, not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the lane's required Chinese summary). --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kGuMFreHR7gCGkpxL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47b6392 commit 5dcbdb4

8 files changed

Lines changed: 333 additions & 29 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/service-print': patch
3+
---
4+
5+
feat(service-print): a record prints on any print page of its object, not only on the default record page
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
`printRecordToFile` (and `POST /api/v1/print/records`) used to refuse every page that was not the object's default record page (`VALIDATION_ERROR` / 400), so an app that printed an object had to turn that object's everyday record page into the printed document. A record now prints on any record page of its object that declares `print`:
11+
12+
- **The default record page** (`isDefault: true`) renders on the console's record route, `CONSOLE/apps/APP/OBJECT/record/ID`, as before.
13+
- **Any other print page** renders on the console's named record-page route, `CONSOLE/apps/APP/OBJECT/record/ID/page/PAGE`, which draws that page and never falls back to the default one. An object keeps its working record page and adds its documents (an invoice, a quote, a delivery note) beside it.
14+
15+
Two refusals remain, both `VALIDATION_ERROR` / 400 before anything is minted or rendered: the page is not a record page of the object, and the page declares no `print`. "A record page of the object" is the console's own reading (`type: 'record'` and that `object`), so the server prints exactly the pages the console's named route draws. An unknown page name is still `RESOURCE_NOT_FOUND` / 404. No metadata key is added or changed: the request already names its page.

‎content/docs/ui/print-pages.mdx‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ export const InvoiceDocumentPage = definePage({
2828
label: 'Invoice',
2929
type: 'record',
3030
object: 'invoice',
31-
kind: 'full',
32-
isDefault: true, // the console renders a record on its default record page
31+
kind: 'full', // not isDefault: the invoice keeps its own working record page
3332
print: { paperSize: 'A4', orientation: 'portrait', repeatHeader: true, pageNumbers: true },
3433
regions: [
3534
{ name: 'header', components: [{ type: 'element:text', properties: { content: 'ACME Ltd' } }] },
@@ -40,9 +39,15 @@ export const InvoiceDocumentPage = definePage({
4039
// …and on the object: enable: { files: true }
4140
```
4241

43-
The server prints a record on the page the console renders it on, which is the
44-
object's **default record page** (`isDefault: true`). A print page that is not
45-
the default is refused rather than printed as some other page.
42+
A record prints on **any record page of its object that declares `print`**, so
43+
an object keeps its everyday record page and adds one or more documents beside
44+
it (an invoice, a delivery note, a quote). The server renders the page the
45+
request names on the console's own routes: the object's default record page
46+
(`isDefault: true`) on the record route, `…/OBJECT/record/ID`, and any other
47+
print page on the named record-page route, `…/OBJECT/record/ID/page/PAGE`. A
48+
page that is not a record page of the object (`type: 'record'` with that
49+
`object`), or that declares no `print`, is refused rather than printed as some
50+
other page.
4651

4752
## Compose the print service
4853

@@ -153,7 +158,7 @@ before a session is minted or a browser is started, in this order:
153158
| 1 | The caller is not a signed-in user acting for themselves (system, agent, service or guest principal; a sign-in held by an authentication policy) | `PERMISSION_DENIED` / 403 |
154159
| 2 | The engine is not ready (`status()` says why) | `SERVICE_UNAVAILABLE` / 503 |
155160
| 3 | No such page | `RESOURCE_NOT_FOUND` / 404 |
156-
| 3 | The page is not the object's default record page, or declares no `print` | `VALIDATION_ERROR` / 400 |
161+
| 3 | The page is not a record page of the object (`type: 'record'`, that `object`), or declares no `print` | `VALIDATION_ERROR` / 400 |
157162
| 4 | The record does not exist, or the caller cannot read it | `RECORD_NOT_FOUND` / 404 |
158163
| 5 | The object does not declare `enable: { files: true }` | `FILES_DISABLED` / 403 |
159164
| 5 | The caller may not create attachments at all | `PERMISSION_DENIED` / 403 |

‎examples/app-showcase/src/ui/pages/invoice-document.page.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import { definePage } from '@objectstack/spec/ui';
99
* the requesting user, and archives the PDF as a file attached to the invoice
1010
* (`showcase_invoice` declares `enable: { files: true }` for that).
1111
*
12-
* It is the invoice's DEFAULT record page (`isDefault: true`) because the
13-
* console renders a record on its default record page, so that is the page a
14-
* record prints on. Every block is from the printable subset
12+
* It is the invoice's DEFAULT record page (`isDefault: true`), so the invoice
13+
* opens on its document. That is this app's choice, not a rule: a record prints
14+
* on any print page of its object, and one that is not the default renders on
15+
* the console's named record-page route. Every block is from the printable subset
1516
* (`PRINTABLE_PAGE_COMPONENT_TYPES`): the header and footer regions are the
1617
* letterhead and the footer, repeated on every sheet, and `record:line_items`
1718
* prints every line up to its cap.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The print page the named-page print dogfood files save (#26583): a project
4+
// brief for `showcase_project`, beside the project's own default record page
5+
// (`showcase_project_detail`, a working page with no `print`). Every block is
6+
// from the printable subset, so the metadata save door accepts it.
7+
8+
/** The project's print page: not its default record page, which stays the working page. */
9+
export const PROJECT_BRIEF_PAGE = {
10+
name: 'showcase_project_brief',
11+
label: 'Project brief',
12+
type: 'record',
13+
object: 'showcase_project',
14+
kind: 'full',
15+
print: { paperSize: 'A4', orientation: 'portrait', pageNumbers: true },
16+
regions: [
17+
{ name: 'header', components: [{ type: 'element:text', properties: { content: 'Project brief' } }] },
18+
{
19+
name: 'main',
20+
components: [
21+
{
22+
type: 'record:details',
23+
properties: {
24+
sections: [{ name: 'brief', label: 'Brief', columns: 2, fields: ['name', 'status', 'start_date', 'end_date'] }],
25+
},
26+
},
27+
],
28+
},
29+
],
30+
} as const;
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// A record printed on a print page that is NOT its object's default record
4+
// page (#26583, ruling A), on a real showcase boot.
5+
//
6+
// `showcase_project` keeps its everyday record page, `showcase_project_detail`
7+
// (the default, a slotted working page with no `print`). A second page, a
8+
// project brief that declares `print`, is saved through the metadata save door
9+
// the way an author adds one, beside it. The print service, over the real
10+
// kernel services, then:
11+
//
12+
// - prints a project on the brief, rendering the console's named record-page
13+
// route (`…/record/ID/page/PAGE`, `RECORD_NAMED_PAGE_ROUTE`), and archives
14+
// the PDF on the project as the requester;
15+
// - still refuses the working page (it declares no `print`) and a print page
16+
// of another object, `VALIDATION_ERROR` / 400, before anything is minted.
17+
//
18+
// The browser and the auth mint are stood in by recording doubles, as in
19+
// `print-service-archive.dogfood.test.ts`; the console route itself is drawn
20+
// against a real Chromium in `print-service-render.dogfood.test.ts`.
21+
22+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
23+
import { mkdtempSync, rmSync } from 'node:fs';
24+
import { tmpdir } from 'node:os';
25+
import { join } from 'node:path';
26+
import type { VerifyStack } from '@objectstack/verify';
27+
import { StorageServicePlugin } from '@objectstack/service-storage';
28+
import { AuditPlugin } from '@objectstack/plugin-audit';
29+
import { bootShowcase } from './showcase-boot.js';
30+
import { PrintServicePlugin } from '../../../services/service-print/src/print-service-plugin.js';
31+
import { PrintService, type PrintEngine } from '../../../services/service-print/src/print-service.js';
32+
import { FILE_ATTACHMENT_SERVICE, type IDataEngine, type IFileAttachmentService } from '@objectstack/spec/contracts';
33+
import { PROJECT_BRIEF_PAGE } from './print-named-page.fixture.js';
34+
35+
const SYS = { isSystem: true } as const;
36+
const CONSOLE = 'http://127.0.0.1:1/_console';
37+
const PDF = Buffer.from('%PDF-1.4\n% named print page dogfood\n%%EOF\n');
38+
const PRINT_ENV = ['OS_PRINT_CHROMIUM_PATH', 'OS_PRINT_BROWSER_ENDPOINT', 'OS_PRINT_CONSOLE_URL'];
39+
40+
interface Refusal {
41+
code: unknown;
42+
status: unknown;
43+
}
44+
45+
async function refusalOf(p: Promise<unknown>): Promise<Refusal> {
46+
try {
47+
await p;
48+
} catch (err) {
49+
return { code: (err as Refusal).code, status: (err as Refusal).status };
50+
}
51+
throw new Error('expected a refusal, the call resolved');
52+
}
53+
54+
describe('a record printed on a print page that is not its object’s default record page', () => {
55+
let stack: VerifyStack;
56+
let rootDir: string;
57+
let ql: IDataEngine;
58+
let service: PrintService;
59+
let project: string;
60+
const saved: Record<string, string | undefined> = {};
61+
const revoke = vi.fn(async () => undefined);
62+
const mint = vi.fn(async () => ({
63+
token: 'stand-in-token',
64+
cookie: { name: 'stand-in', value: 'stand-in-cookie', path: '/', httpOnly: true, secure: false },
65+
revoke,
66+
}));
67+
const renderPdf = vi.fn(async () => PDF);
68+
69+
beforeAll(async () => {
70+
for (const k of PRINT_ENV) {
71+
saved[k] = process.env[k];
72+
delete process.env[k];
73+
}
74+
rootDir = mkdtempSync(join(tmpdir(), 'print-named-page-'));
75+
stack = await bootShowcase({
76+
extraPlugins: [
77+
new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }),
78+
new AuditPlugin(),
79+
new PrintServicePlugin(),
80+
],
81+
});
82+
ql = stack.kernel.getService<IDataEngine>('objectql');
83+
// The brief is added the way an author adds a page: through the metadata save door.
84+
const protocol = await stack.kernel.getServiceAsync<{ saveMetaItem(r: Record<string, unknown>): Promise<unknown> }>('protocol');
85+
await protocol.saveMetaItem({ type: 'page', name: PROJECT_BRIEF_PAGE.name, item: PROJECT_BRIEF_PAGE });
86+
const [seeded] = await ql.find('showcase_project', { limit: 1 }, { context: SYS });
87+
project = String(seeded!.id);
88+
89+
service = new PrintService({
90+
placement: { state: 'configured', resolved: { placement: 'local', target: '/stand-in/chromium', consoleUrl: CONSOLE } },
91+
driver: { placement: 'local', probe: async () => ({ version: 'stand-in' }), renderPdf },
92+
getEngine: () => stack.kernel.getService<PrintEngine>('objectql'),
93+
getAttachments: () => stack.kernel.getService<IFileAttachmentService>(FILE_ATTACHMENT_SERVICE),
94+
getMinter: () => ({ mint }),
95+
});
96+
await service.probe();
97+
}, 180_000);
98+
99+
afterAll(async () => {
100+
await stack?.stop();
101+
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
102+
for (const k of PRINT_ENV) {
103+
if (saved[k] === undefined) delete process.env[k];
104+
else process.env[k] = saved[k];
105+
}
106+
});
107+
108+
const request = (page: string, object = 'showcase_project', recordId = project) => ({ app: 'showcase', object, recordId, page });
109+
110+
it('precondition: the project keeps its working default record page, and the brief is a second page beside it', () => {
111+
const engine = stack.kernel.getService<PrintEngine>('objectql');
112+
expect(engine.registry.getItem<Record<string, unknown>>('page', 'showcase_project_detail')).toMatchObject({
113+
object: 'showcase_project',
114+
isDefault: true,
115+
});
116+
expect(engine.registry.getItem<Record<string, unknown>>('page', 'showcase_project_detail')?.print).toBeUndefined();
117+
const brief = engine.registry.getItem<Record<string, unknown>>('page', PROJECT_BRIEF_PAGE.name);
118+
expect(brief).toMatchObject({ type: 'record', object: 'showcase_project', print: { paperSize: 'A4' } });
119+
expect(brief?.isDefault).toBeUndefined();
120+
});
121+
122+
it('prints the project on the brief through the console’s named record-page route, archived as the requester', async () => {
123+
const ctx = await stack.contextFor(await stack.signIn());
124+
mint.mockClear();
125+
revoke.mockClear();
126+
renderPdf.mockClear();
127+
const out = await service.printRecordToFile(request(PROJECT_BRIEF_PAGE.name), ctx);
128+
expect(renderPdf).toHaveBeenCalledTimes(1);
129+
expect(renderPdf).toHaveBeenCalledWith(
130+
expect.objectContaining({ url: `${CONSOLE}/apps/showcase/showcase_project/record/${project}/page/${PROJECT_BRIEF_PAGE.name}` }),
131+
);
132+
expect(revoke).toHaveBeenCalledTimes(1);
133+
expect(out).toMatchObject({ size: PDF.byteLength, filename: `${PROJECT_BRIEF_PAGE.name}-${project}.pdf` });
134+
const attachment = await ql.findOne('sys_attachment', { where: { id: out.attachmentId } }, { context: SYS });
135+
expect(attachment).toMatchObject({ parent_object: 'showcase_project', parent_id: project, file_id: out.fileId, uploaded_by: ctx.userId });
136+
});
137+
138+
it('the working default page, which declares no print, is still refused: VALIDATION_ERROR / 400, nothing minted', async () => {
139+
const ctx = await stack.contextFor(await stack.signIn());
140+
mint.mockClear();
141+
renderPdf.mockClear();
142+
expect(await refusalOf(service.printRecordToFile(request('showcase_project_detail'), ctx))).toEqual({
143+
code: 'VALIDATION_ERROR',
144+
status: 400,
145+
});
146+
expect(mint).not.toHaveBeenCalled();
147+
expect(renderPdf).not.toHaveBeenCalled();
148+
});
149+
150+
it('the brief cannot print a record of another object: VALIDATION_ERROR / 400, nothing minted', async () => {
151+
const ctx = await stack.contextFor(await stack.signIn());
152+
const [invoice] = await ql.find('showcase_invoice', { limit: 1 }, { context: SYS });
153+
mint.mockClear();
154+
renderPdf.mockClear();
155+
expect(
156+
await refusalOf(service.printRecordToFile(request(PROJECT_BRIEF_PAGE.name, 'showcase_invoice', String(invoice!.id)), ctx)),
157+
).toEqual({ code: 'VALIDATION_ERROR', status: 400 });
158+
expect(mint).not.toHaveBeenCalled();
159+
expect(renderPdf).not.toHaveBeenCalled();
160+
});
161+
});

‎packages/qa/dogfood/test/print-service-render.dogfood.test.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
// `sys_file.owner_id` and `sys_attachment.uploaded_by` the requester;
1919
// - the minted session gone after the print — after a success and after a
2020
// forced failure (the archive's byte store refusing after the render);
21-
// - the minted cookie in no refusal message.
21+
// - the minted cookie in no refusal message;
22+
// - a project printed on a print page that is NOT its object's default record
23+
// page (the brief of `print-named-page.fixture.ts`, saved through the
24+
// metadata save door), through the console's named record-page route
25+
// (#26583).
2226
//
2327
// Needs two things this suite's CI job does not build: the Console bundle
2428
// (`packages/console/dist`, `pnpm objectui:build`) and a Chromium
@@ -38,6 +42,7 @@ import { StorageServicePlugin } from '@objectstack/service-storage';
3842
import { AuditPlugin } from '@objectstack/plugin-audit';
3943
import type { IAuthService, IDataEngine, IStorageService, PrincipalSession } from '@objectstack/spec/contracts';
4044
import { bootShowcase } from './showcase-boot.js';
45+
import { PROJECT_BRIEF_PAGE } from './print-named-page.fixture.js';
4146
import { createConsoleStaticPlugin } from '../../../cli/src/utils/console.js';
4247
import { PrintServicePlugin } from '../../../services/service-print/src/print-service-plugin.js';
4348
import type { IPrintService } from '../../../services/service-print/src/print-service.js';
@@ -180,6 +185,8 @@ for (const placement of ['local', 'remote'] as const) {
180185

181186
print = stack.kernel.getService<IPrintService>('print');
182187
ql = stack.kernel.getService<IDataEngine>('objectql');
188+
const protocol = await stack.kernel.getServiceAsync<{ saveMetaItem(r: Record<string, unknown>): Promise<unknown> }>('protocol');
189+
await protocol.saveMetaItem({ type: 'page', name: PROJECT_BRIEF_PAGE.name, item: PROJECT_BRIEF_PAGE });
183190
// Observe each mint (the plugin reads the auth service per call): keep the
184191
// cookie so the test can ask, afterwards, whether it still opens a session.
185192
const auth = stack.kernel.getService<IAuthService>('auth');
@@ -243,6 +250,30 @@ for (const placement of ['local', 'remote'] as const) {
243250
expect(await opensASession(minted[0]!)).toEqual([401, 'UNAUTHENTICATED']);
244251
}, 120_000);
245252

253+
it('prints a project on a print page that is not its default record page, through the named record-page route', async () => {
254+
const token = await stack.signIn();
255+
const ctx = await stack.contextFor(token);
256+
const [project] = await ql.find('showcase_project', { limit: 1 }, { context: SYS });
257+
const projectId = String(project!.id);
258+
sessionUsers.length = 0;
259+
minted.length = 0;
260+
261+
const out = await print.printRecordToFile(
262+
{ app: APP, object: 'showcase_project', recordId: projectId, page: PROJECT_BRIEF_PAGE.name },
263+
ctx,
264+
);
265+
266+
expect(new Set(sessionUsers)).toEqual(new Set([ctx.userId]));
267+
const file = await ql.findOne('sys_file', { where: { id: out.fileId } }, { context: SYS });
268+
const attachment = await ql.findOne('sys_attachment', { where: { id: out.attachmentId } }, { context: SYS });
269+
expect(file).toMatchObject({ status: 'committed', mime_type: 'application/pdf', owner_id: ctx.userId });
270+
expect(attachment).toMatchObject({ parent_object: 'showcase_project', parent_id: projectId, uploaded_by: ctx.userId });
271+
const bytes = await stack.kernel.getService<IStorageService>('storage').download(String(file!.key));
272+
expect(bytes.subarray(0, 5).toString('latin1')).toBe('%PDF-');
273+
expect(minted).toHaveLength(1);
274+
expect(await opensASession(minted[0]!)).toEqual([401, 'UNAUTHENTICATED']);
275+
}, 120_000);
276+
246277
it('a print that fails after the render still revokes the session, stores nothing, and names no credential', async () => {
247278
const token = await stack.signIn();
248279
const ctx = await stack.contextFor(token);

0 commit comments

Comments
 (0)