11// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
33import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest' ;
4+ import chalk from 'chalk' ;
45import { LiteKernel , ObjectLogger , type Plugin , type PluginContext } from '@objectstack/core' ;
56import { runActionGovernanceInventory } from '@objectstack/objectql' ;
67import { AutomationServicePlugin } from '@objectstack/service-automation' ;
@@ -93,17 +94,22 @@ const BASE: ServerReadyOptions = {
9394} ;
9495
9596let transcript : string [ ] ;
97+ /** The same lines with their SGR kept — for the pins that read a line's color. */
98+ let rawTranscript : string [ ] ;
9699let errSpy : ReturnType < typeof vi . spyOn > ;
97100
98101/** Strip SGR so assertions hold whether or not chalk colors this run. */
99102const plain = ( s : string ) => s . replace ( / \u001b \[ [ 0 - 9 ; ] * m / g, '' ) ;
100103
101104const linesWith = ( needle : string ) => transcript . filter ( ( line ) => line . includes ( needle ) ) ;
105+ const rawLinesWith = ( needle : string ) => rawTranscript . filter ( ( line ) => plain ( line ) . includes ( needle ) ) ;
102106
103107beforeEach ( ( ) => {
104108 transcript = [ ] ;
109+ rawTranscript = [ ] ;
105110 errSpy = vi . spyOn ( console , 'error' ) . mockImplementation ( ( ...args : unknown [ ] ) => {
106111 for ( const line of plain ( args . join ( ' ' ) ) . split ( '\n' ) ) transcript . push ( line ) ;
112+ rawTranscript . push ( ...args . join ( ' ' ) . split ( '\n' ) ) ;
107113 } ) ;
108114} ) ;
109115
@@ -206,6 +212,10 @@ describe('a real automation boot with eight scheduled flows and scheduled work o
206212 // The class's short text is the reason's first sentence — cause and switch.
207213 expect ( scheduleLines [ 0 ] ) . toContain ( 'disabled by deployment policy' ) ;
208214 expect ( scheduleLines [ 0 ] ) . toContain ( SCHEDULED_WORK_ENV ) ;
215+ // [#22255] Information, not a warning — and asserted against the REAL
216+ // producer's recorded reason, so the printer's identity with it is pinned
217+ // here: a reworded producer turns this line back into a `⚠`.
218+ expect ( scheduleLines [ 0 ] ) . toMatch ( / ^ { 2 } ℹ 8 f l o w s d e c l a r e / ) ;
209219 } ) ;
210220
211221 it ( 'prints no warning twice — every flow named on exactly one line' , async ( ) => {
@@ -280,7 +290,7 @@ describe('one line per warning class (formatter)', () => {
280290
281291 const firstSentence = SCHEDULED_WORK_DISABLED_REASON . slice ( 0 , SCHEDULED_WORK_DISABLED_REASON . indexOf ( '. This' ) ) ;
282292 expect ( linesWith ( 'NOT bound' ) ) . toEqual ( [
283- ` ⚠ 1 flow declares a 'schedule' trigger but is NOT bound — ${ firstSentence } : a_flow` ,
293+ ` ℹ 1 flow declares a 'schedule' trigger but is NOT bound — ${ firstSentence } : a_flow` ,
284294 ] ) ;
285295 expect ( linesWith ( LONG_EXPLANATION ) ) . toEqual ( [ ] ) ;
286296 expect ( linesWith ( '--log-level debug' ) ) . toHaveLength ( 1 ) ;
@@ -298,6 +308,111 @@ describe('one line per warning class (formatter)', () => {
298308 } ) ;
299309} ) ;
300310
311+ // ---------------------------------------------------------------------------
312+ // #22255 — the deployment-policy class is information
313+ // ---------------------------------------------------------------------------
314+
315+ /**
316+ * Flows left unbound because the deployment switched scheduled work off — its
317+ * documented default — are an expected degradation, so their class line
318+ * prints dim under `ℹ` (the maintainer's direction on #22160, 「预期中的降级记
319+ * info」). Every other unbound reason is the author's to fix and keeps its
320+ * yellow `⚠`. Only the glyph and color move: the text, the order and the
321+ * records the banner hands back to Boot diagnostics are #22073's, unchanged.
322+ */
323+ describe ( 'the deployment-policy class prints as information (#22255, formatter)' , ( ) => {
324+ const missingTrigger =
325+ "no 'schedule' trigger is registered — add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)" ;
326+ const bindingFailed = "trigger 'schedule' is registered but binding failed — see earlier warnings" ;
327+ const firstSentence = SCHEDULED_WORK_DISABLED_REASON . slice ( 0 , SCHEDULED_WORK_DISABLED_REASON . indexOf ( '. This' ) ) ;
328+ /** chalk's opening SGR for `dim` and for `yellow`. */
329+ const DIM = '\u001b[2m' ;
330+ const YELLOW = '\u001b[33m' ;
331+
332+ /** Print with color on whatever this run's stderr is: the color split is half the pin. */
333+ function printColored ( opts : ServerReadyOptions ) : void {
334+ const prior = chalk . level ;
335+ chalk . level = 1 ;
336+ try {
337+ printServerReady ( opts ) ;
338+ } finally {
339+ chalk . level = prior ;
340+ }
341+ }
342+
343+ it ( 'prints the policy class dim under ℹ with its flows and first sentence; a missing trigger and a binding failure keep a yellow ⚠' , ( ) => {
344+ printColored ( {
345+ ...BASE ,
346+ automation : summary ( {
347+ unbound : [
348+ ...policyRefused ( [ 'a_flow' , 'b_flow' ] ) ,
349+ { flowName : 'c_flow' , triggerType : 'schedule' , reason : missingTrigger } ,
350+ { flowName : 'd_flow' , triggerType : 'schedule' , reason : bindingFailed } ,
351+ ] ,
352+ } ) ,
353+ } ) ;
354+
355+ expect ( linesWith ( 'NOT bound' ) ) . toEqual ( [
356+ ` ℹ 2 flows declare a 'schedule' trigger but are NOT bound — ${ firstSentence } : a_flow, b_flow` ,
357+ ` ⚠ 1 flow declares a 'schedule' trigger but is NOT bound — ${ missingTrigger } : c_flow` ,
358+ ` ⚠ 1 flow declares a 'schedule' trigger but is NOT bound — ${ bindingFailed } : d_flow` ,
359+ ] ) ;
360+ const [ policy , missing , failed ] = rawLinesWith ( 'NOT bound' ) ;
361+ expect ( policy . startsWith ( DIM ) , JSON . stringify ( policy ) ) . toBe ( true ) ;
362+ expect ( policy ) . not . toContain ( YELLOW ) ;
363+ expect ( missing . startsWith ( YELLOW ) , JSON . stringify ( missing ) ) . toBe ( true ) ;
364+ expect ( failed . startsWith ( YELLOW ) , JSON . stringify ( failed ) ) . toBe ( true ) ;
365+ } ) ;
366+
367+ it ( '⛔ judges the class by identity with the producer, never by its words — a reason that only quotes the policy keeps ⚠' , ( ) => {
368+ const quoting = [
369+ // A binding failure whose text carries the policy's first sentence.
370+ `trigger 'schedule' is registered but binding failed — ${ firstSentence } ` ,
371+ // The whole policy sentence inside a longer record — the shape the
372+ // schedule trigger's own refusal log takes.
373+ `[ScheduleTrigger] flow 'q1_flow' is not armed: ${ SCHEDULED_WORK_DISABLED_REASON } ` ,
374+ // The first sentence alone, as a reworded producer could leave it.
375+ firstSentence ,
376+ ] ;
377+ printColored ( {
378+ ...BASE ,
379+ automation : summary ( {
380+ unbound : quoting . map ( ( reason , i ) => ( { flowName : `q${ i } _flow` , triggerType : 'schedule' , reason } ) ) ,
381+ } ) ,
382+ } ) ;
383+
384+ const lines = linesWith ( 'NOT bound' ) ;
385+ expect ( lines , transcript . join ( '\n' ) ) . toHaveLength ( quoting . length ) ;
386+ for ( const line of lines ) expect ( line ) . toMatch ( / ^ { 2 } ⚠ / ) ;
387+ for ( const line of rawLinesWith ( 'NOT bound' ) ) expect ( line . startsWith ( YELLOW ) , JSON . stringify ( line ) ) . toBe ( true ) ;
388+ } ) ;
389+
390+ it ( 'hands back the same restated records under either glyph — each flow is named once, and Boot diagnostics prints nothing' , ( ) => {
391+ printServerReady ( {
392+ ...BASE ,
393+ automation : summary ( {
394+ unbound : [
395+ ...policyRefused ( [ 'a_flow' ] ) ,
396+ { flowName : 'b_flow' , triggerType : 'schedule' , reason : missingTrigger } ,
397+ { flowName : 'c_flow' , triggerType : 'schedule' , reason : bindingFailed } ,
398+ ] ,
399+ } ) ,
400+ bootDiagnostics : {
401+ lines : [
402+ auditRecord ( 'a_flow' ) ,
403+ auditRecord ( 'b_flow' , 'schedule' , missingTrigger ) ,
404+ auditRecord ( 'c_flow' , 'schedule' , bindingFailed ) ,
405+ ] ,
406+ } ,
407+ } ) ;
408+
409+ for ( const name of [ 'a_flow' , 'b_flow' , 'c_flow' ] ) {
410+ expect ( linesWith ( name ) , transcript . join ( '\n' ) ) . toHaveLength ( 1 ) ;
411+ }
412+ expect ( linesWith ( 'Boot diagnostics' ) ) . toEqual ( [ ] ) ;
413+ } ) ;
414+ } ) ;
415+
301416describe ( 'print-once: Boot diagnostics withholds what the banner restated (formatter)' , ( ) => {
302417 // A boot warning no banner section restates. Shaped as the runtime-assets
303418 // plugin's branding warning (`describeUnservedBrandingAssets` in
0 commit comments