|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #24289 — a hook's `ctx.title(field?)` answers the same on both doors of |
| 5 | + * `bootStack`: the source boot, where the hook's inline `handler` runs |
| 6 | + * in-process, and the artifact door, where the `body` the artifact carries runs |
| 7 | + * in the sandbox, as `objectstack dev` serves it. |
| 8 | + * |
| 9 | + * Measured before (`origin/main` ec749fbb4): on the source boot every insert was |
| 10 | + * refused with `TypeError: ctx.title is not a function`, while the artifact's |
| 11 | + * body answered. The accessor was installed on the sandbox path only, so an app |
| 12 | + * could not test a hook that used it. |
| 13 | + * |
| 14 | + * Pinned on each door, for one `beforeInsert`: |
| 15 | + * |
| 16 | + * - `ctx.title()`: this record's title, a formula that reads one hop through a |
| 17 | + * lookup, so the in-flight foreign key is followed; |
| 18 | + * - `ctx.title('product')`: the related record's own formula title; |
| 19 | + * - `ctx.title('spare')`: an empty lookup, `null` and never an id. |
| 20 | + */ |
| 21 | + |
| 22 | +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; |
| 23 | +import { tmpdir } from 'node:os'; |
| 24 | +import { join } from 'node:path'; |
| 25 | +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; |
| 26 | +import { defineStack } from '@objectstack/spec'; |
| 27 | +import { bootStack, type VerifyStack } from './harness.js'; |
| 28 | + |
| 29 | +const BOOT_TIMEOUT = 120_000; |
| 30 | + |
| 31 | +const manifest = { id: 'com.example.hook-title-door', name: 'Hook Title Door', namespace: 'htd', version: '1.0.0', type: 'app' }; |
| 32 | + |
| 33 | +const product = { |
| 34 | + name: 'htd_product', label: 'Product', pluralLabel: 'Products', sharingModel: 'public_read_write', |
| 35 | + nameField: 'label', |
| 36 | + fields: { |
| 37 | + name: { name: 'name', type: 'text', label: 'Name', required: true }, |
| 38 | + code: { name: 'code', type: 'text', label: 'Code' }, |
| 39 | + label: { name: 'label', type: 'formula', label: 'Label', expression: "record.name + ' (' + record.code + ')'" }, |
| 40 | + }, |
| 41 | +}; |
| 42 | + |
| 43 | +const line = { |
| 44 | + name: 'htd_line', label: 'Line', pluralLabel: 'Lines', sharingModel: 'public_read_write', |
| 45 | + nameField: 'title', |
| 46 | + fields: { |
| 47 | + name: { name: 'name', type: 'text', label: 'Name', required: true }, |
| 48 | + product: { name: 'product', type: 'lookup', label: 'Product', reference: 'htd_product' }, |
| 49 | + spare: { name: 'spare', type: 'lookup', label: 'Spare', reference: 'htd_product' }, |
| 50 | + title: { name: 'title', type: 'formula', label: 'Title', expression: "record.name + ' · ' + record.product.name" }, |
| 51 | + own_title: { name: 'own_title', type: 'text', label: 'Own title' }, |
| 52 | + product_title: { name: 'product_title', type: 'text', label: 'Product title' }, |
| 53 | + spare_title: { name: 'spare_title', type: 'text', label: 'Spare title' }, |
| 54 | + }, |
| 55 | +}; |
| 56 | + |
| 57 | +/** One statement set, run in-process by the handler and in the sandbox by the body. */ |
| 58 | +const SOURCE = [ |
| 59 | + 'ctx.input.own_title = String(await ctx.title());', |
| 60 | + "ctx.input.product_title = String(await ctx.title('product'));", |
| 61 | + "ctx.input.spare_title = String(await ctx.title('spare'));", |
| 62 | +].join('\n'); |
| 63 | + |
| 64 | +type TitleCtx = { input: Record<string, unknown>; title: (field?: string) => Promise<string | null> }; |
| 65 | + |
| 66 | +/** The configuration: the hook is an inline function, which a source boot runs in-process. */ |
| 67 | +const app = (): Record<string, unknown> => |
| 68 | + defineStack({ |
| 69 | + manifest, |
| 70 | + objects: [product, line], |
| 71 | + hooks: [ |
| 72 | + { |
| 73 | + name: 'htd_name_line', |
| 74 | + object: 'htd_line', |
| 75 | + events: ['beforeInsert'], |
| 76 | + handler: async (ctx: TitleCtx) => { |
| 77 | + ctx.input.own_title = String(await ctx.title()); |
| 78 | + ctx.input.product_title = String(await ctx.title('product')); |
| 79 | + ctx.input.spare_title = String(await ctx.title('spare')); |
| 80 | + }, |
| 81 | + }, |
| 82 | + ], |
| 83 | + } as never) as unknown as Record<string, unknown>; |
| 84 | + |
| 85 | +/** The artifact: the same app, its hook carrying the same statements as a `body`. */ |
| 86 | +const artifact = (): Record<string, unknown> => |
| 87 | + JSON.parse( |
| 88 | + JSON.stringify( |
| 89 | + defineStack({ |
| 90 | + manifest, |
| 91 | + objects: [product, line], |
| 92 | + hooks: [ |
| 93 | + { |
| 94 | + name: 'htd_name_line', |
| 95 | + object: 'htd_line', |
| 96 | + events: ['beforeInsert'], |
| 97 | + body: { language: 'js', source: SOURCE, capabilities: ['api.read'] }, |
| 98 | + }, |
| 99 | + ], |
| 100 | + } as never), |
| 101 | + ), |
| 102 | + ) as Record<string, unknown>; |
| 103 | + |
| 104 | +/** What the body answers on a served runtime, the answer the source boot owes. */ |
| 105 | +const EXPECTED = { own: 'L1 · Widget', product: 'Widget (W-1)', spare: 'null' }; |
| 106 | + |
| 107 | +let hostRoot: string; |
| 108 | +beforeAll(() => { |
| 109 | + hostRoot = mkdtempSync(join(tmpdir(), 'verify-hook-title-door-')); |
| 110 | + mkdirSync(join(hostRoot, 'dist')); |
| 111 | + writeFileSync(join(hostRoot, 'dist', 'objectstack.json'), JSON.stringify(artifact())); |
| 112 | +}); |
| 113 | +afterAll(() => { |
| 114 | + if (hostRoot) rmSync(hostRoot, { recursive: true, force: true }); |
| 115 | +}); |
| 116 | + |
| 117 | +const live: VerifyStack[] = []; |
| 118 | +afterEach(async () => { |
| 119 | + for (const stack of live.splice(0)) await stack.stop(); |
| 120 | +}); |
| 121 | + |
| 122 | +const titlesOn = async (stack: VerifyStack): Promise<Record<string, unknown>> => { |
| 123 | + live.push(stack); |
| 124 | + const widget = await stack.hooks.run('htd_product', 'insert', { name: 'Widget', code: 'W-1' }, { system: true }); |
| 125 | + const row = await stack.hooks.run('htd_line', 'insert', { name: 'L1', product: widget.id }, { system: true }); |
| 126 | + return { own: row.own_title, product: row.product_title, spare: row.spare_title }; |
| 127 | +}; |
| 128 | + |
| 129 | +describe('#24289: a hook\'s ctx.title answers the same on both bootStack doors', () => { |
| 130 | + it('the artifact door: the body, in the sandbox, as `objectstack dev` serves it', async () => { |
| 131 | + expect(await titlesOn(await bootStack(app(), { hostRoot, artifact: 'dist/objectstack.json' }))).toEqual(EXPECTED); |
| 132 | + }, BOOT_TIMEOUT); |
| 133 | + |
| 134 | + it('the source door: the inline handler, in-process, gets the same answers', async () => { |
| 135 | + expect(await titlesOn(await bootStack(app(), { hostRoot }))).toEqual(EXPECTED); |
| 136 | + }, BOOT_TIMEOUT); |
| 137 | +}); |
0 commit comments