@raidiant/notifai 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +20 -4
  2. package/dist/atomic-file.d.ts +4 -0
  3. package/dist/atomic-file.js +60 -10
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +25 -2
  6. package/dist/client.js +45 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands.d.ts +85 -6
  9. package/dist/commands.js +1133 -322
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +62 -0
  12. package/dist/config-schema.js +312 -0
  13. package/dist/config-schema.js.map +1 -0
  14. package/dist/config.d.ts +38 -3
  15. package/dist/config.js +61 -14
  16. package/dist/config.js.map +1 -1
  17. package/dist/file-lock.d.ts +26 -0
  18. package/dist/file-lock.js +268 -0
  19. package/dist/file-lock.js.map +1 -0
  20. package/dist/harnesses.d.ts +28 -0
  21. package/dist/harnesses.js +40 -0
  22. package/dist/harnesses.js.map +1 -0
  23. package/dist/hook-adapter.d.ts +27 -0
  24. package/dist/hook-adapter.js +188 -0
  25. package/dist/hook-adapter.js.map +1 -0
  26. package/dist/hook-input.d.ts +10 -0
  27. package/dist/hook-input.js +64 -0
  28. package/dist/hook-input.js.map +1 -0
  29. package/dist/hooks.d.ts +66 -10
  30. package/dist/hooks.js +751 -276
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/install-hooks.d.ts +40 -16
  33. package/dist/install-hooks.js +217 -73
  34. package/dist/install-hooks.js.map +1 -1
  35. package/dist/interactive.d.ts +4 -0
  36. package/dist/interactive.js +604 -0
  37. package/dist/interactive.js.map +1 -0
  38. package/dist/logging.d.ts +142 -0
  39. package/dist/logging.js +533 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/main.js +201 -51
  42. package/dist/main.js.map +1 -1
  43. package/dist/opencode-plugin.d.ts +7 -7
  44. package/dist/opencode-plugin.js +12 -13
  45. package/dist/opencode-plugin.js.map +1 -1
  46. package/dist/ui/banner.d.ts +22 -0
  47. package/dist/ui/banner.js +87 -0
  48. package/dist/ui/banner.js.map +1 -0
  49. package/dist/ui/config-view.d.ts +32 -0
  50. package/dist/ui/config-view.js +108 -0
  51. package/dist/ui/config-view.js.map +1 -0
  52. package/dist/ui/help.d.ts +46 -0
  53. package/dist/ui/help.js +105 -0
  54. package/dist/ui/help.js.map +1 -0
  55. package/dist/ui/theme.d.ts +108 -0
  56. package/dist/ui/theme.js +261 -0
  57. package/dist/ui/theme.js.map +1 -0
  58. package/package.json +11 -9
  59. package/src/atomic-file.ts +86 -7
  60. package/src/client.test.ts +12 -0
  61. package/src/client.ts +72 -4
  62. package/src/commands.test.ts +971 -44
  63. package/src/commands.ts +1323 -337
  64. package/src/config-schema.test.ts +152 -0
  65. package/src/config-schema.ts +363 -0
  66. package/src/config.test.ts +6 -5
  67. package/src/config.ts +85 -16
  68. package/src/file-lock-process-worker.test.ts +64 -0
  69. package/src/file-lock.test.ts +279 -0
  70. package/src/file-lock.ts +318 -0
  71. package/src/harnesses.test.ts +48 -0
  72. package/src/harnesses.ts +59 -0
  73. package/src/hook-adapter.test.ts +187 -0
  74. package/src/hook-adapter.ts +212 -0
  75. package/src/hook-input.test.ts +39 -0
  76. package/src/hook-input.ts +72 -0
  77. package/src/hooks.test.ts +1318 -240
  78. package/src/hooks.ts +1046 -296
  79. package/src/install-hooks.test.ts +90 -41
  80. package/src/install-hooks.ts +259 -74
  81. package/src/interactive.ts +692 -0
  82. package/src/logging-process-worker.test.ts +39 -0
  83. package/src/logging.test.ts +453 -0
  84. package/src/logging.ts +639 -0
  85. package/src/main.ts +220 -48
  86. package/src/opencode-plugin.ts +15 -16
  87. package/src/session-end-log-lock-worker.test.ts +35 -0
  88. package/src/skill.test.ts +11 -0
  89. package/src/ui/banner.ts +95 -0
  90. package/src/ui/config-view.test.ts +88 -0
  91. package/src/ui/config-view.ts +138 -0
  92. package/src/ui/help.ts +124 -0
  93. package/src/ui/theme.test.ts +92 -0
  94. package/src/ui/theme.ts +279 -0
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { CONFIG_KEYS, loadConfig } from '../config.js'
5
+ import { configInfo } from '../config-schema.js'
6
+ import { renderConfigExplain, renderConfigList, renderConfigPlain } from './config-view.js'
7
+ import { width } from './theme.js'
8
+
9
+ const config = loadConfig({
10
+ cwd: os.tmpdir(),
11
+ env: { XDG_CONFIG_HOME: path.join(os.tmpdir(), 'notifai-config-view-tests') },
12
+ })
13
+
14
+ describe('renderConfigPlain', () => {
15
+ it('is the flat form scripts already parse', () => {
16
+ // This is the agent-facing shape and the reason the pretty renderer is
17
+ // gated on a human terminal. Restyling it would be a breaking change for
18
+ // an audience that cannot see the styling.
19
+ const lines = renderConfigPlain(config, false)
20
+ expect(lines).toHaveLength(CONFIG_KEYS.length)
21
+ expect(lines[0]).toBe(`base_url = ${JSON.stringify(config.base_url.value)}`)
22
+ for (const line of lines) expect(line).toMatch(/^[a-z_]+ = /)
23
+ })
24
+
25
+ it('appends the raw source tag under --explain', () => {
26
+ const lines = renderConfigPlain(config, true)
27
+ expect(lines[0]).toBe(
28
+ `base_url = ${JSON.stringify(config.base_url.value)} [${config.base_url.source}]`,
29
+ )
30
+ })
31
+
32
+ it('carries no colour, so a pipe gets no escape bytes', () => {
33
+ for (const line of renderConfigPlain(config, true)) {
34
+ expect(line).toBe(line.replace(/\[[0-9;]*m/g, ''))
35
+ }
36
+ })
37
+ })
38
+
39
+ describe('renderConfigList', () => {
40
+ it('names every non-advanced key and explains it', () => {
41
+ const text = renderConfigList(config).join('\n')
42
+ for (const key of CONFIG_KEYS) {
43
+ // Derived, not listed: a hardcoded set of advanced keys silently becomes
44
+ // an assertion about yesterday's key list the moment a key is added.
45
+ if (configInfo(key).advanced === true) continue
46
+ expect(text, key).toContain(key)
47
+ }
48
+ expect(text).toContain('Questions & presence')
49
+ expect(text).toContain('notifai config explain')
50
+ })
51
+
52
+ it('reveals advanced keys only when asked', () => {
53
+ expect(renderConfigList(config).join('\n')).not.toContain('base_url')
54
+ expect(renderConfigList(config, { showAdvanced: true }).join('\n')).toContain('base_url')
55
+ })
56
+
57
+ it('fits the terminal, so no row wraps into the next', () => {
58
+ const columns = process.stdout.columns
59
+ Object.defineProperty(process.stdout, 'columns', { value: 80, configurable: true })
60
+ try {
61
+ for (const line of renderConfigList(config, { showAdvanced: true })) {
62
+ expect(width(line), line).toBeLessThanOrEqual(80)
63
+ }
64
+ } finally {
65
+ Object.defineProperty(process.stdout, 'columns', { value: columns, configurable: true })
66
+ }
67
+ })
68
+ })
69
+
70
+ describe('renderConfigExplain', () => {
71
+ it('answers what it is, what it is now, and what it accepts', () => {
72
+ const text = renderConfigExplain('require_idle', config).join('\n')
73
+ expect(text).toContain('require_idle')
74
+ expect(text).toContain('Only when I have stepped away')
75
+ expect(text).toContain('Accepts')
76
+ expect(text).toContain('true or false')
77
+ // The copyable next step; the old output stopped at the value.
78
+ expect(text).toContain('notifai config set require_idle')
79
+ })
80
+
81
+ it('lists what each choice of an enum means', () => {
82
+ const text = renderConfigExplain('sound', config).join('\n')
83
+ for (const choice of ['default', 'done', 'attention', 'alert', 'none']) {
84
+ expect(text, choice).toContain(choice)
85
+ }
86
+ expect(text).toContain('the completion chime')
87
+ })
88
+ })
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Rendering for the configuration surfaces a person reads.
3
+ *
4
+ * The agent-facing shapes are untouched and live elsewhere: `--json` is the
5
+ * contract, and the flat `key = value` form stays exactly as it was for
6
+ * anything that is not a terminal. What is new is only what a human sees.
7
+ */
8
+ import { CONFIG_KEYS, type CliConfig, type ConfigKey } from '../config.js'
9
+ import {
10
+ CONFIG_GROUPS,
11
+ acceptedValues,
12
+ configInfo,
13
+ configKeysByGroup,
14
+ describeSource,
15
+ formatValue,
16
+ } from '../config-schema.js'
17
+ import { glyph, pad, style, terminalWidth, width, wrap } from './theme.js'
18
+
19
+ /**
20
+ * The full settings listing, grouped and explained.
21
+ *
22
+ * Each key gets its name (what you type), its value (what is in force), where
23
+ * that value came from, and one line on what it does. The name leads rather
24
+ * than the friendly label because the next thing a reader does with it is type
25
+ * it into `config set`.
26
+ */
27
+ export function renderConfigList(
28
+ config: CliConfig,
29
+ options: { showAdvanced?: boolean } = {},
30
+ ): string[] {
31
+ const columns = terminalWidth()
32
+ const lines: string[] = []
33
+ const nameWidth = Math.max(...CONFIG_KEYS.map((key) => key.length))
34
+ const SUMMARY_INDENT = ' '
35
+
36
+ for (const group of CONFIG_GROUPS) {
37
+ const keys = configKeysByGroup(group.id).filter(
38
+ (info) => options.showAdvanced === true || info.advanced !== true,
39
+ )
40
+ if (keys.length === 0) continue
41
+
42
+ lines.push('')
43
+ lines.push(style.heading(group.title))
44
+ for (const line of wrap(group.blurb, columns - 2)) lines.push(style.dim(` ${line}`))
45
+ lines.push('')
46
+
47
+ for (const info of keys) {
48
+ const entry = config[info.key]
49
+ const source = describeSource(entry.source)
50
+ const value = formatValue(info.key, entry.value)
51
+ const isSet = source.label !== 'default'
52
+ // The provenance mark is only drawn when there is provenance to show;
53
+ // an empty dim segment still emits escape codes, so a `trimEnd` on the
54
+ // finished string cannot remove it.
55
+ const provenance = isSet ? ` ${style.dim(`${glyph.bullet} ${source.label}`)}` : ''
56
+ lines.push(
57
+ ` ${style.code(pad(info.key, nameWidth))} ` +
58
+ `${isSet ? style.value(value) : style.unset(value)}${provenance}`,
59
+ )
60
+ for (const line of wrap(info.summary, columns - SUMMARY_INDENT.length)) {
61
+ lines.push(style.dim(`${SUMMARY_INDENT}${line}`))
62
+ }
63
+ }
64
+ }
65
+
66
+ lines.push('')
67
+ lines.push(
68
+ style.dim(
69
+ `${glyph.arrow} ${style.code('notifai config explain <key>')} for what one of these means and how to change it.`,
70
+ ),
71
+ )
72
+ return lines
73
+ }
74
+
75
+ /**
76
+ * Everything about one key, for someone who has asked about it specifically.
77
+ *
78
+ * This is the payoff of the listing above staying terse: the reader arrives
79
+ * here already knowing which key they care about, so the full paragraph, the
80
+ * legal values, and the layer the current value came from can all be spent on
81
+ * that one key.
82
+ */
83
+ export function renderConfigExplain(key: ConfigKey, config: CliConfig): string[] {
84
+ const info = configInfo(key)
85
+ const entry = config[key]
86
+ const source = describeSource(entry.source)
87
+ const columns = terminalWidth()
88
+ const lines: string[] = []
89
+
90
+ lines.push('')
91
+ lines.push(`${style.code(key)} ${style.dim(glyph.bullet)} ${style.heading(info.label)}`)
92
+ lines.push('')
93
+
94
+ for (const paragraph of info.detail.split('\n\n')) {
95
+ for (const line of wrap(paragraph, columns - 2)) lines.push(` ${line}`)
96
+ lines.push('')
97
+ }
98
+
99
+ const valueLabel = formatValue(key, entry.value)
100
+ lines.push(
101
+ ` ${style.dim(pad('Now', 10))}${style.value(valueLabel)} ${style.dim(`${glyph.bullet} ${source.label}`)}`,
102
+ )
103
+ if (source.path !== null) lines.push(` ${style.dim(pad('', 10))}${style.dim(source.path)}`)
104
+ lines.push(` ${style.dim(pad('Accepts', 10))}${acceptedValues(key)}`)
105
+
106
+ if (info.choices !== undefined && info.choiceHints !== undefined) {
107
+ lines.push('')
108
+ for (const choice of info.choices) {
109
+ const hint = info.choiceHints[choice]
110
+ lines.push(
111
+ ` ${style.dim(pad('', 10))}${style.code(pad(choice, 14))}${style.dim(hint ?? '')}`.trimEnd(),
112
+ )
113
+ }
114
+ }
115
+
116
+ lines.push('')
117
+ const example = info.example ?? (info.choices?.[0] ?? 'value')
118
+ lines.push(` ${style.dim('Change it')} ${style.code(`notifai config set ${key} ${example}`)}`)
119
+ if (entry.source !== 'default' && entry.source !== 'flag') {
120
+ lines.push(` ${style.dim('Inherit it')} ${style.code(`notifai config unset ${key}`)}`)
121
+ }
122
+ lines.push('')
123
+ return lines
124
+ }
125
+
126
+ /** The flat form, unchanged, for anything that is not a person at a terminal. */
127
+ export function renderConfigPlain(config: CliConfig, explain: boolean): string[] {
128
+ return CONFIG_KEYS.map((key) => {
129
+ const entry = config[key]
130
+ const provenance = explain ? ` [${entry.source}]` : ''
131
+ return `${key} = ${JSON.stringify(entry.value)}${provenance}`
132
+ })
133
+ }
134
+
135
+ /** Longest visible line, for callers that need to size a rule or a box. */
136
+ export function widestLine(lines: readonly string[]): number {
137
+ return Math.max(0, ...lines.map((line) => width(line)))
138
+ }
package/src/ui/help.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Help that answers "what can I do here", not "what strings does argv accept".
3
+ *
4
+ * The old help listed seventeen commands in one flat column, with the two a new
5
+ * user needs (`init`, `login`) sitting between a platform capability dump and
6
+ * an entry point marked "Internal:". Everything was equally prominent, which is
7
+ * the same as nothing being prominent.
8
+ *
9
+ * Grouping and colour are Commander's own (`commandsGroup`, `optionsGroup`, and
10
+ * the `styleX` hooks it calls when the output has colours); this module only
11
+ * supplies the palette and the group vocabulary. Commander turns its styling
12
+ * off by itself when the output is not a terminal, so piped help stays plain
13
+ * without a second code path.
14
+ */
15
+ import { Help, type Command, type HelpConfiguration, type Option } from 'commander'
16
+ import { glyph, style } from './theme.js'
17
+
18
+ /**
19
+ * Group headings, in the order a reader meets them.
20
+ *
21
+ * Ordering is by who needs it and when: first run, then daily use, then the
22
+ * commands agents drive, then the things touched once or never. Commander
23
+ * renders groups in first-use order, so these constants are also the layout.
24
+ */
25
+ export const GROUP = {
26
+ start: 'Getting started:',
27
+ daily: 'Everyday:',
28
+ agent: 'For agents and scripts:',
29
+ advanced: 'Account and wiring:',
30
+ help: 'Help:',
31
+ } as const
32
+
33
+ /** Option groups for `send`, which carries thirty flags. */
34
+ export const SEND_GROUP = {
35
+ content: 'What it says:',
36
+ routing: 'Where it goes:',
37
+ presentation: 'How it arrives:',
38
+ reply: 'Asking for an answer:',
39
+ advanced: 'Advanced:',
40
+ } as const
41
+
42
+ /** Group order, independent of the order commands happen to be registered in. */
43
+ const GROUP_ORDER: readonly string[] = [
44
+ GROUP.start,
45
+ GROUP.daily,
46
+ GROUP.agent,
47
+ GROUP.advanced,
48
+ GROUP.help,
49
+ ]
50
+
51
+ function groupRank(heading: string): number {
52
+ const index = GROUP_ORDER.indexOf(heading)
53
+ // Anything not named above keeps its position relative to its peers, because
54
+ // the sort is stable — which is what lets the same override run over option
55
+ // groups without needing to know their headings.
56
+ return index === -1 ? GROUP_ORDER.length : index
57
+ }
58
+
59
+ export const helpConfiguration: HelpConfiguration = {
60
+ // Groups already carry the intended order; alphabetising would scatter the
61
+ // dependency between `login` and everything that needs a credential.
62
+ sortSubcommands: false,
63
+ sortOptions: false,
64
+ showGlobalOptions: false,
65
+
66
+ /**
67
+ * Commander seeds group order from the raw registration order, which ties
68
+ * how the help reads to where a `program.command(…)` block happens to sit in
69
+ * the file — move one and the help silently reorders. Sorting the finished
70
+ * grouping makes `GROUP_ORDER` the single place that decision is made.
71
+ *
72
+ * The same override runs over option groups, whose headings are not in
73
+ * `GROUP_ORDER` at all. They all rank equal, and `sort` is stable, so they
74
+ * keep the order they were declared in.
75
+ */
76
+ groupItems<T extends Command | Option>(
77
+ this: Help,
78
+ unsortedItems: T[],
79
+ visibleItems: T[],
80
+ getGroup: (item: T) => string,
81
+ ): Map<string, T[]> {
82
+ // `.call` cannot carry the method's own generic through, so the signature
83
+ // is restated once here instead of widening every caller to
84
+ // `Command | Option`.
85
+ const base = Help.prototype.groupItems as unknown as (
86
+ this: Help,
87
+ unsorted: T[],
88
+ visible: T[],
89
+ group: (item: T) => string,
90
+ ) => Map<string, T[]>
91
+ const grouped = base.call(this, unsortedItems, visibleItems, getGroup)
92
+ return new Map([...grouped.entries()].sort((a, b) => groupRank(a[0]) - groupRank(b[0])))
93
+ },
94
+
95
+ styleTitle: (title: string): string => style.heading(title),
96
+ styleCommandText: (text: string): string => style.accent(text),
97
+ styleSubcommandTerm: (text: string): string => style.code(text),
98
+ styleOptionTerm: (text: string): string => style.code(text),
99
+ styleArgumentTerm: (text: string): string => style.code(text),
100
+ styleDescriptionText: (text: string): string => text,
101
+ styleUsage: (text: string): string => text,
102
+ }
103
+
104
+ /**
105
+ * Footer for the root help.
106
+ *
107
+ * Its whole job is to make the interactive app discoverable, because someone
108
+ * reading a wall of flags has already demonstrated they did not know it was
109
+ * there.
110
+ */
111
+ export function rootHelpFooter(): string {
112
+ const lines = [
113
+ '',
114
+ style.heading('Start here'),
115
+ ` ${style.code('notifai')} ${style.dim(
116
+ `${glyph.arrow} the interactive app: status, settings, devices, test sends`,
117
+ )}`,
118
+ ` ${style.code('notifai init')} ${style.dim(`${glyph.arrow} set this project up, step by step`)}`,
119
+ '',
120
+ style.dim(`Every command takes ${style.code('--help')}. Machine-readable output: ${style.code('--json')}.`),
121
+ style.dim(`Settings are explained by ${style.code('notifai config show')} and ${style.code('notifai config explain <key>')}.`),
122
+ ]
123
+ return lines.join('\n')
124
+ }
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { card, clip, pad, width, wrap } from './theme.js'
3
+
4
+ /**
5
+ * These run with colour off — vitest's stdout is not a TTY, so `picocolors`
6
+ * resolves to identity functions. That is the interesting case: it is the one
7
+ * every agent, pipe and CI log gets, and the one where a layout bug is
8
+ * invisible to a human developer reading a colourful terminal.
9
+ */
10
+
11
+ const RED = ''
12
+ const RESET = ''
13
+
14
+ describe('width', () => {
15
+ it('counts printable characters, not escape bytes', () => {
16
+ expect(width('hello')).toBe(5)
17
+ expect(width(`${RED}hello${RESET}`)).toBe(5)
18
+ })
19
+ })
20
+
21
+ describe('pad', () => {
22
+ it('pads by visible width so colour cannot break a column', () => {
23
+ expect(width(pad(`${RED}ab${RESET}`, 6))).toBe(6)
24
+ expect(pad('ab', 4)).toBe('ab ')
25
+ })
26
+
27
+ it('never truncates when the text already exceeds the column', () => {
28
+ expect(pad('abcdef', 3)).toBe('abcdef')
29
+ })
30
+ })
31
+
32
+ describe('wrap', () => {
33
+ it('breaks on whitespace within the limit', () => {
34
+ expect(wrap('one two three four', 9)).toEqual(['one two', 'three', 'four'])
35
+ })
36
+
37
+ it('returns bare lines, leaving indentation to the caller', () => {
38
+ // The regression this guards: wrap used to indent continuation lines
39
+ // itself while callers indented every line, so each wrapped paragraph
40
+ // stepped further right down the page.
41
+ for (const line of wrap('alpha beta gamma delta', 11)) {
42
+ expect(line.startsWith(' ')).toBe(false)
43
+ }
44
+ })
45
+
46
+ it('keeps a word that is longer than the limit rather than losing it', () => {
47
+ expect(wrap('supercalifragilistic', 5)).toEqual(['supercalifragilistic'])
48
+ })
49
+ })
50
+
51
+ describe('clip', () => {
52
+ it('leaves text that already fits', () => {
53
+ expect(clip('short', 10)).toBe('short')
54
+ })
55
+
56
+ it('truncates by visible width and never mid-escape', () => {
57
+ const clipped = clip(`${RED}abcdefghij${RESET}`, 5)
58
+ expect(width(clipped)).toBeLessThanOrEqual(5)
59
+ // A slice by String.length would cut through `` and spill the
60
+ // raw bytes into the terminal.
61
+ expect(clipped).toContain(RED)
62
+ expect(clipped.endsWith(RESET)).toBe(true)
63
+ })
64
+ })
65
+
66
+ describe('card', () => {
67
+ it('draws a closed box whose rows all share one width', () => {
68
+ const lines = card('Status', ['a', 'bbbbbbbbbb'], 40)
69
+ const widths = new Set(lines.map((line) => width(line)))
70
+ expect(widths.size).toBe(1)
71
+ expect(lines[0]).toContain('Status')
72
+ })
73
+
74
+ it('measures rows by visible width, so colour does not skew the border', () => {
75
+ const plain = card('T', ['abcdef'], 40)
76
+ const coloured = card('T', [`${RED}abcdef${RESET}`], 40)
77
+ expect(plain.map(width)).toEqual(coloured.map(width))
78
+ })
79
+
80
+ it('survives a terminal that reports no usable width', () => {
81
+ // A pty reporting zero columns made the obvious library call compute a
82
+ // negative pad and throw, taking the whole interactive app with it. A
83
+ // status card may degrade; it must never be the thing that crashes.
84
+ expect(() => card('Status', ['something reasonably long here'], 0)).not.toThrow()
85
+ expect(card('Status', ['x'], 0).length).toBe(3)
86
+ })
87
+
88
+ it('clips a row too wide for the terminal instead of wrapping it', () => {
89
+ const lines = card('T', ['x'.repeat(200)], 40)
90
+ for (const line of lines) expect(width(line)).toBeLessThanOrEqual(40)
91
+ })
92
+ })