Skip to content

Commit f63e6a1

Browse files
committed
test(rest,cli): pin the view-container by-name read on both boots, and its changeset
Claude-Session: https://claude.ai/code/session_01HGa2XMKBRUqPrZFxQavBwH Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6fcd4ed commit f63e6a1

3 files changed

Lines changed: 542 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
`GET /api/v1/meta/view/<object>`, the by-name read of a `defineView` container, answers `200` on an artifact boot, and translates the views inside the container on every boot
6+
7+
Clause-②: no
8+
9+
The registry files a `defineView({ object, list, listViews, … })` container under the object it binds, and serves it on the by-name read of that key. The read handed the container to the translator for one view, which reads the view's own `name`.
10+
11+
- **Artifact boot** (`os build`, then `os serve` of the artifact): a compiled container carries no `name`, so the read answered `500 INTERNAL_ERROR` and the log read `Cannot read properties of undefined (reading 'startsWith')`. It now answers `200`.
12+
- **Config boot** (`os serve objectstack.config.ts`): its registrar stamps `name: <object>` onto the container, so the read answered `200`. But it served a `label` equal to the object name, which no author wrote, and it left every view inside the container in the authored language. That label is no longer served.
13+
- **What is translated now, on both boots.** Each view inside the container (`list`, `form`, each `listViews` and `formViews` entry) carries the same `label`, `description` and bulk-action copy as the by-name read of the view it expands to (`<object>.<key>`). That copy is keyed by the same `objects.<object>._views.<key>` entry and judged against the same packaged base, so an organization's explicit override still beats the catalog. The container's own `label` and `description` are read from `objects.<object>._views.<object>`, and are left unchanged when the catalog has no entry there.
14+
- **What stays the same.** The by-name reads of the expanded views and the `GET /api/v1/meta/view` list are unchanged. The list still never includes a container.
15+
- **Packaging.** `@objectstack/rest` now lists `@objectstack/metadata` under `dependencies` (it was a dev dependency). The read imports `deriveViewContainerObject` from `@objectstack/metadata/view-container`, the registry's own derivation of the object a container binds.
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22614 — the by-name read of a `defineView` container answers the same on an
5+
* ARTIFACT boot and on a CONFIG boot of one single-package project, with every
6+
* view inside it translated.
7+
*
8+
* ## The defect, as measured through the doors
9+
*
10+
* `GET /api/v1/meta/view/acme_note`, authenticated, where `acme_note` is the
11+
* key the registry files the project's `defineView({ object: 'acme_note', … })`
12+
* container under:
13+
*
14+
* - `os build`, then `os serve` of the artifact: `500 INTERNAL_ERROR`, the log
15+
* reading `Cannot read properties of undefined (reading 'startsWith')`. The
16+
* compiled container carries no `name`, and the read handed it to the
17+
* single-view translator, which keys the catalog on `name`;
18+
* - `os serve objectstack.config.ts`: `200`, from a container the config
19+
* boot's registrar had stamped `name: 'acme_note'` onto — served with a
20+
* fabricated `label: 'acme_note'` and both views inside it in English for a
21+
* `zh-CN` reader.
22+
*
23+
* The read now classifies the served view document once and translates a
24+
* container as a container (`translateMetaDocument`, `@objectstack/rest`;
25+
* pinned function-level by `meta-view-container-i18n.test.ts` there).
26+
*
27+
* ## What each boot is asked
28+
*
29+
* - THE CARD'S READ, in `zh-CN` and in `en`: the container, whose views carry
30+
* the catalog's labels for the reader's locale, and no label the project
31+
* never wrote;
32+
* - CONTROL: the by-name reads of the two views it expands to
33+
* (`acme_note.default`, `acme_note.mine`), translated as they always were.
34+
*
35+
* ⚠️ Not counts: each assertion reads a label the catalog carries for exactly
36+
* one view and one locale, so a read that stopped translating, or translated
37+
* under the wrong key, names the view it got wrong.
38+
*/
39+
40+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
41+
import { spawn, execFile, type ChildProcessWithoutNullStreams } from 'node:child_process';
42+
import { existsSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs';
43+
import { tmpdir } from 'node:os';
44+
import { join } from 'node:path';
45+
import {
46+
CLI,
47+
E2E_SECRET_KEY,
48+
TSX,
49+
childEnv,
50+
portContentionError,
51+
portDriftError,
52+
probeThroughChild,
53+
randomPort,
54+
} from './helpers/serve-process.js';
55+
import { linkSpec } from './helpers/define-stack-fixture.js';
56+
57+
/** The banner's tail — every row above it has printed. */
58+
const READY = /Press Ctrl\+C to stop/;
59+
60+
const OBJ = 'acme_note';
61+
62+
/** One package, no `packages[]`; one container with a default list and one named list, and a catalog for both. */
63+
const CONFIG = `
64+
import { defineStack } from '@objectstack/spec';
65+
const grid = (label) => ({ type: 'grid', label, data: { provider: 'object', object: '${OBJ}' }, columns: ['title'] });
66+
const noteObj = { name: '${OBJ}', label: 'Note', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } };
67+
const noteViews = { object: '${OBJ}', list: grid('All Notes'), listViews: { mine: grid('My Notes') } };
68+
const app = { id: 'com.example.acme', name: 'acme', namespace: 'acme', version: '1.0.0', type: 'app' };
69+
const translations = [{
70+
en: { objects: { ${OBJ}: { label: 'Note', _views: { default: { label: 'All Notes' }, mine: { label: 'My Notes' } } } } },
71+
'zh-CN': { objects: { ${OBJ}: { label: '笔记', _views: { default: { label: '全部笔记' }, mine: { label: '我的笔记' } } } } },
72+
}];
73+
export default defineStack({ manifest: app, objects: [noteObj], views: [noteViews], translations }, { strict: false });
74+
`;
75+
76+
const dirs: string[] = [];
77+
const children: ChildProcessWithoutNullStreams[] = [];
78+
79+
function project(prefix: string): string {
80+
const dir = mkdtempSync(join(tmpdir(), prefix));
81+
dirs.push(dir);
82+
writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG, 'utf8');
83+
linkSpec(dir);
84+
return dir;
85+
}
86+
87+
interface Run { code: number; stdout: string; stderr: string }
88+
89+
/** `os build` in `dir`, through the source entry. */
90+
function build(dir: string): Promise<Run> {
91+
return new Promise((done) => {
92+
execFile(
93+
TSX,
94+
[CLI, 'build'],
95+
// `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00).
96+
{ cwd: dir, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
97+
(err, stdout, stderr) => done({
98+
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
99+
stdout: String(stdout),
100+
stderr: String(stderr),
101+
}),
102+
);
103+
});
104+
}
105+
106+
/** `os build`, then take the config away so `os serve` boots the compiled artifact. */
107+
async function buildArtifact(dir: string): Promise<void> {
108+
const built = await build(dir);
109+
if (built.code !== 0 || !existsSync(join(dir, 'dist', 'objectstack.json'))) {
110+
throw new Error(`os build failed in ${dir} (exit ${built.code})\n${built.stdout}\n${built.stderr}`);
111+
}
112+
renameSync(join(dir, 'objectstack.config.ts'), join(dir, 'objectstack.config.ts.off'));
113+
}
114+
115+
interface LiveServe {
116+
child: ChildProcessWithoutNullStreams;
117+
output: () => string;
118+
}
119+
120+
/** Boot `os serve` in `dir` with `args`; the source entry seeds the dev admin either way. */
121+
function bootServe(dir: string, args: string[], port: string): Promise<LiveServe> {
122+
return new Promise((resolveBoot, rejectBoot) => {
123+
const child = spawn(TSX, [CLI, 'serve', ...args, '-p', port], {
124+
cwd: dir,
125+
stdio: ['pipe', 'pipe', 'pipe'],
126+
// `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00).
127+
env: childEnv({
128+
NO_COLOR: '1',
129+
OS_DATABASE_URL: ':memory:',
130+
OS_LOG_LEVEL: 'warn',
131+
OS_DISABLE_CONSOLE: '1',
132+
OS_SECRET_KEY: E2E_SECRET_KEY,
133+
}),
134+
}) as ChildProcessWithoutNullStreams;
135+
children.push(child);
136+
137+
const what = `os serve ${args.join(' ')} in ${dir}`;
138+
let out = '';
139+
let settled = false;
140+
const settle = (err: Error | null) => {
141+
if (settled) return;
142+
settled = true;
143+
clearTimeout(timer);
144+
if (err) rejectBoot(err);
145+
else resolveBoot({ child, output: () => out });
146+
};
147+
const timer = setTimeout(
148+
() => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)),
149+
180_000,
150+
);
151+
const onData = (d: unknown) => {
152+
out += String(d);
153+
// The child is the authority on the port it bound (#12525).
154+
if (READY.test(out)) settle(portDriftError(out, what, port));
155+
};
156+
child.stdout.on('data', onData);
157+
child.stderr.on('data', onData);
158+
child.on('exit', (code) =>
159+
settle(
160+
portContentionError(out, what, port)
161+
?? new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`),
162+
),
163+
);
164+
});
165+
}
166+
167+
async function stop(child: ChildProcessWithoutNullStreams): Promise<void> {
168+
if (child.exitCode !== null || child.signalCode !== null) return;
169+
await new Promise<void>((done) => {
170+
const give = setTimeout(() => {
171+
try {
172+
child.kill('SIGKILL');
173+
} catch {
174+
/* already gone */
175+
}
176+
done();
177+
}, 10_000);
178+
child.once('exit', () => {
179+
clearTimeout(give);
180+
done();
181+
});
182+
try {
183+
child.kill('SIGTERM');
184+
} catch {
185+
clearTimeout(give);
186+
done();
187+
}
188+
});
189+
}
190+
191+
/** One HTTP exchange, attributed to the child if the transport fails (#15653). ⛔ No assertion inside. */
192+
async function request(serve: LiveServe, what: string, url: string, init: RequestInit = {}): Promise<{ status: number; body: any }> {
193+
return probeThroughChild(
194+
{
195+
child: serve.child,
196+
transcript: () => `\n--- child output ---\n${serve.output().slice(-4000)}`,
197+
label: 'serve-view-container-read',
198+
what,
199+
},
200+
async () => {
201+
const r = await fetch(url, init);
202+
const text = await r.text();
203+
let body: unknown = text;
204+
try { body = JSON.parse(text); } catch { /* keep the text */ }
205+
return { status: r.status, body };
206+
},
207+
);
208+
}
209+
210+
/** One by-name read, as a boot answered it. */
211+
interface Answer { status: number; item: any; error?: unknown }
212+
213+
/** What one boot answers for the container and for the two views it expands to, per locale. */
214+
type Reading = Record<string, Answer>;
215+
216+
const READS = [OBJ, `${OBJ}.default`, `${OBJ}.mine`];
217+
const LOCALES = ['zh-CN', 'en'];
218+
219+
/** Boot, sign in as the seeded admin, read every by-name view in every locale, stop. */
220+
async function read(dir: string, args: string[]): Promise<{ reading: Reading; output: string }> {
221+
const port = randomPort();
222+
const base = `http://localhost:${port}/api/v1`;
223+
const serve = await bootServe(dir, args, port);
224+
try {
225+
const signIn = await request(serve, 'the sign-in probe', `${base}/auth/sign-in/email`, {
226+
method: 'POST',
227+
headers: { 'content-type': 'application/json' },
228+
body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }),
229+
});
230+
const token = signIn.body?.token;
231+
if (signIn.status !== 200 || typeof token !== 'string') {
232+
throw new Error(`sign-in answered ${signIn.status}: ${JSON.stringify(signIn.body)}\n--- output ---\n${serve.output().slice(-3000)}`);
233+
}
234+
const reading: Reading = {};
235+
for (const name of READS) {
236+
for (const locale of LOCALES) {
237+
const answer = await request(serve, `the read of view ${name} in ${locale}`, `${base}/meta/view/${name}`, {
238+
headers: { authorization: `Bearer ${token}`, 'accept-language': locale },
239+
});
240+
reading[`${name} ${locale}`] = {
241+
status: answer.status,
242+
item: answer.body?.item,
243+
...(answer.status === 200 ? {} : { error: answer.body }),
244+
};
245+
}
246+
}
247+
return { reading, output: serve.output() };
248+
} finally {
249+
await stop(serve.child);
250+
}
251+
}
252+
253+
/** The view content of a served container — what a translation can change, without the registrar's stamps. */
254+
function containerContent(item: any): Record<string, unknown> {
255+
const { object, label, list, listViews, form, formViews } = item ?? {};
256+
return { object, label, list, listViews, form, formViews };
257+
}
258+
259+
afterAll(async () => {
260+
for (const child of children) await stop(child);
261+
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
262+
}, 60_000);
263+
264+
describe('#22614 — the by-name read of a view container, on an artifact boot and on a config boot', () => {
265+
let config: { reading: Reading; output: string };
266+
let artifact: { reading: Reading; output: string };
267+
268+
beforeAll(async () => {
269+
config = await read(project('view-container-read-config-'), ['objectstack.config.ts']);
270+
const built = project('view-container-read-artifact-');
271+
await buildArtifact(built);
272+
artifact = await read(built, []);
273+
}, 480_000);
274+
275+
it.each([
276+
['the artifact boot', () => artifact],
277+
['the config boot', () => config],
278+
])('%s answers 200 for the container, its views translated for a zh-CN reader', (_door, boot) => {
279+
const { reading, output } = boot();
280+
const answer = reading[`${OBJ} zh-CN`];
281+
// Before the fix: the artifact boot answered 500 here.
282+
expect(answer.status, `${JSON.stringify(answer.error)}\n--- output ---\n${output.slice(-3000)}`).toBe(200);
283+
// Before the fix: the config boot served both in English.
284+
expect(answer.item.list.label).toBe('全部笔记');
285+
expect(answer.item.listViews.mine.label).toBe('我的笔记');
286+
// Before the fix: the config boot served a `label` equal to the object name.
287+
expect(answer.item).not.toHaveProperty('label');
288+
expect(output).not.toMatch(/reading 'startsWith'/);
289+
});
290+
291+
it.each([
292+
['the artifact boot', () => artifact],
293+
['the config boot', () => config],
294+
])('%s serves the authored labels to an en reader', (_door, boot) => {
295+
const answer = boot().reading[`${OBJ} en`];
296+
expect(answer.status, JSON.stringify(answer.error)).toBe(200);
297+
expect(answer.item.list.label).toBe('All Notes');
298+
expect(answer.item.listViews.mine.label).toBe('My Notes');
299+
});
300+
301+
it('the two boots serve the same container', () => {
302+
for (const locale of LOCALES) {
303+
expect(containerContent(config.reading[`${OBJ} ${locale}`].item), locale)
304+
.toEqual(containerContent(artifact.reading[`${OBJ} ${locale}`].item));
305+
}
306+
});
307+
308+
it('CONTROL — the by-name reads of the views it expands to are translated as before, alike on both boots', () => {
309+
const expected: Record<string, string> = {
310+
[`${OBJ}.default zh-CN`]: '全部笔记',
311+
[`${OBJ}.default en`]: 'All Notes',
312+
[`${OBJ}.mine zh-CN`]: '我的笔记',
313+
[`${OBJ}.mine en`]: 'My Notes',
314+
};
315+
for (const [key, label] of Object.entries(expected)) {
316+
for (const boot of [artifact, config]) {
317+
const answer = boot.reading[key];
318+
expect(answer.status, `${key}: ${JSON.stringify(answer.error)}`).toBe(200);
319+
expect(answer.item.label, key).toBe(label);
320+
}
321+
}
322+
});
323+
});

0 commit comments

Comments
 (0)