@barefootjs/test 0.17.0 → 0.17.1

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.
package/dist/index.js CHANGED
@@ -187348,6 +187348,7 @@ function tsNodeToParsedExpr(node) {
187348
187348
  }
187349
187349
  var CALLBACK_METHODS = new Set([
187350
187350
  "filter",
187351
+ "map",
187351
187352
  "every",
187352
187353
  "some",
187353
187354
  "find",
@@ -195464,6 +195465,9 @@ var JS_BUILTINS = new Set([
195464
195465
  var ENV_SIGNAL_CLIENT_FACTORY = {
195465
195466
  search: "createSearchParams"
195466
195467
  };
195468
+ var ENV_SIGNAL_READERS = new Map([
195469
+ ["search", { key: "search", canonicalName: "searchParams", methods: new Set(["get"]) }]
195470
+ ]);
195467
195471
  function queryHrefLocalNames(metadata) {
195468
195472
  const names = new Set;
195469
195473
  for (const imp of metadata.imports) {
@@ -195668,9 +195672,12 @@ var import_typescript15 = __toESM(require_typescript(), 1);
195668
195672
  var import_typescript16 = __toESM(require_typescript(), 1);
195669
195673
  var UNRESOLVED = Symbol("unresolved");
195670
195674
  var NO_RETURN = Symbol("no-return");
195675
+
195676
+ // ../jsx/src/augment-inherited-props.ts
195677
+ var import_typescript17 = __toESM(require_typescript(), 1);
195671
195678
  // ../jsx/src/shared-program.ts
195672
195679
  init_path();
195673
- var import_typescript17 = __toESM(require_typescript(), 1);
195680
+ var import_typescript18 = __toESM(require_typescript(), 1);
195674
195681
  // ../jsx/src/adapters/interface.ts
195675
195682
  class BaseAdapter {
195676
195683
  renderChildren(children) {
@@ -196184,9 +196191,9 @@ function registerBuiltinLoweringPlugins() {
196184
196191
  registerLoweringPlugin(plugin);
196185
196192
  }
196186
196193
  // ../jsx/src/combine-client-js.ts
196187
- var import_typescript18 = __toESM(require_typescript(), 1);
196188
- // ../jsx/src/debug.ts
196189
196194
  var import_typescript19 = __toESM(require_typescript(), 1);
196195
+ // ../jsx/src/debug.ts
196196
+ var import_typescript20 = __toESM(require_typescript(), 1);
196190
196197
  function escapeForIdBoundary(name) {
196191
196198
  return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
196192
196199
  }
@@ -196278,8 +196285,6 @@ function resolveSetters(handler, setterToSignal, fnSetters) {
196278
196285
  return refs;
196279
196286
  }
196280
196287
  // ../jsx/src/profiler.ts
196281
- var import_typescript20 = __toESM(require_typescript(), 1);
196282
- // ../jsx/src/augment-inherited-props.ts
196283
196288
  var import_typescript21 = __toESM(require_typescript(), 1);
196284
196289
 
196285
196290
  // ../jsx/src/index.ts
@@ -196378,6 +196383,7 @@ function irNodeToTestNode(node, constantMap, metadata) {
196378
196383
  const cmap = constantMap ?? new Map;
196379
196384
  const setterToSignal = new Map;
196380
196385
  const fnSetters = new Map;
196386
+ const defaults = new Map;
196381
196387
  if (metadata) {
196382
196388
  for (const s of metadata.signals) {
196383
196389
  if (s.setter)
@@ -196386,8 +196392,24 @@ function irNodeToTestNode(node, constantMap, metadata) {
196386
196392
  for (const [k, v] of buildLocalFunctionSetterMap(metadata, setterToSignal)) {
196387
196393
  fnSetters.set(k, v);
196388
196394
  }
196395
+ for (const p of metadata.propsParams) {
196396
+ const lit = p.defaultValue !== undefined ? parseLiteralDefault(p.defaultValue) : null;
196397
+ if (lit !== null)
196398
+ defaults.set(p.name, lit);
196399
+ }
196400
+ }
196401
+ return convert(node, { cmap, setterToSignal, fnSetters, defaults });
196402
+ }
196403
+ function parseLiteralDefault(js) {
196404
+ const v = js.trim();
196405
+ if (v.startsWith("'") && v.endsWith("'") && !v.slice(1, -1).includes("'") || v.startsWith('"') && v.endsWith('"') && !v.slice(1, -1).includes('"')) {
196406
+ return v.slice(1, -1);
196389
196407
  }
196390
- return convert(node, { cmap, setterToSignal, fnSetters });
196408
+ if (/^-?\d+(\.\d+)?$/.test(v))
196409
+ return v;
196410
+ if (v === "true" || v === "false")
196411
+ return v;
196412
+ return null;
196391
196413
  }
196392
196414
  function convert(node, ctx) {
196393
196415
  switch (node.type) {
@@ -196396,7 +196418,7 @@ function convert(node, ctx) {
196396
196418
  case "text":
196397
196419
  return convertText(node);
196398
196420
  case "expression":
196399
- return convertExpression(node);
196421
+ return convertExpression(node, ctx);
196400
196422
  case "conditional":
196401
196423
  return convertConditional(node, ctx);
196402
196424
  case "loop":
@@ -196426,19 +196448,9 @@ function convertElement(node, ctx) {
196426
196448
  let dataState = null;
196427
196449
  let classes = [];
196428
196450
  for (const attr of node.attrs) {
196429
- const value = resolveAttrValue(attr);
196451
+ const value = resolveAttrValue(attr, ctx);
196430
196452
  if (attr.name === "className" || attr.name === "class") {
196431
- if (typeof value === "string") {
196432
- const isDynamic = attr.value.kind === "expression" || attr.value.kind === "template" || attr.value.kind === "spread";
196433
- if (isDynamic && ctx.cmap.size > 0) {
196434
- const resolved = resolveClassValue(value, ctx.cmap);
196435
- if (resolved !== null) {
196436
- classes = resolved.split(/\s+/).filter(Boolean);
196437
- continue;
196438
- }
196439
- }
196440
- classes = value.split(/\s+/).filter(Boolean);
196441
- }
196453
+ classes = collectClassTokens(attr.value, value, ctx);
196442
196454
  continue;
196443
196455
  }
196444
196456
  if (attr.name === "role") {
@@ -196501,12 +196513,13 @@ function convertText(node) {
196501
196513
  componentName: null
196502
196514
  });
196503
196515
  }
196504
- function convertExpression(node) {
196516
+ function convertExpression(node, ctx) {
196517
+ const text = ctx.defaults.get(node.expr.trim()) ?? node.expr;
196505
196518
  return new TestNode({
196506
196519
  tag: null,
196507
196520
  type: "expression",
196508
196521
  children: [],
196509
- text: node.expr,
196522
+ text,
196510
196523
  props: {},
196511
196524
  classes: [],
196512
196525
  role: null,
@@ -196571,7 +196584,7 @@ function convertComponent(node, ctx) {
196571
196584
  props[prop.name] = prop.value.expr;
196572
196585
  break;
196573
196586
  case "template":
196574
- props[prop.name] = resolveTemplateAttr(prop.value);
196587
+ props[prop.name] = resolveTemplateAttr(prop.value, ctx);
196575
196588
  break;
196576
196589
  case "boolean-shorthand":
196577
196590
  case "boolean-attr":
@@ -196711,41 +196724,69 @@ function refsToHandler(refs) {
196711
196724
  }
196712
196725
  return { setters, via };
196713
196726
  }
196714
- function resolveClassValue(value, cmap) {
196715
- if (cmap.has(value)) {
196716
- return cmap.get(value);
196727
+ function splitClassTokens(value) {
196728
+ return value.split(/\s+/).filter((t) => t && !t.includes("${"));
196729
+ }
196730
+ function collectClassTokens(attrValue, resolved, ctx) {
196731
+ if (attrValue.kind === "template") {
196732
+ const joined = attrValue.parts.map((part) => {
196733
+ if (part.type === "string")
196734
+ return substituteInterpolations(part.value, ctx);
196735
+ if (part.type === "ternary")
196736
+ return `${part.whenTrue} ${part.whenFalse}`;
196737
+ return Object.values(part.cases).join(" ");
196738
+ }).join("");
196739
+ return splitClassTokens(joined);
196740
+ }
196741
+ if (typeof resolved !== "string")
196742
+ return [];
196743
+ const isDynamic = attrValue.kind === "expression" || attrValue.kind === "spread";
196744
+ if (isDynamic) {
196745
+ const value = resolveClassValue(resolved, ctx);
196746
+ if (value !== null)
196747
+ return splitClassTokens(value);
196748
+ }
196749
+ return splitClassTokens(resolved);
196750
+ }
196751
+ function substituteInterpolations(value, ctx) {
196752
+ return value.replace(/\$\{([^}]+)\}/g, (raw, expr) => {
196753
+ const trimmed = expr.trim();
196754
+ return ctx.cmap.get(trimmed) ?? ctx.defaults.get(trimmed) ?? raw;
196755
+ });
196756
+ }
196757
+ function resolveClassValue(value, ctx) {
196758
+ if (ctx.cmap.has(value)) {
196759
+ return ctx.cmap.get(value);
196760
+ }
196761
+ if (ctx.defaults.has(value)) {
196762
+ return ctx.defaults.get(value);
196717
196763
  }
196718
196764
  if (value.startsWith("`") && value.endsWith("`")) {
196719
- const inner = value.slice(1, -1);
196720
- return inner.replace(/\$\{([^}]+)\}/g, (_, expr) => {
196721
- const trimmed = expr.trim();
196722
- return cmap.get(trimmed) ?? "";
196723
- });
196765
+ return substituteInterpolations(value.slice(1, -1), ctx);
196724
196766
  }
196725
196767
  return null;
196726
196768
  }
196727
- function resolveAttrValue(attr) {
196769
+ function resolveAttrValue(attr, ctx) {
196728
196770
  switch (attr.value.kind) {
196729
196771
  case "boolean-attr":
196730
196772
  return true;
196731
196773
  case "literal":
196732
196774
  return attr.value.value;
196733
196775
  case "expression":
196734
- return attr.value.expr;
196735
196776
  case "spread":
196736
- return attr.value.expr;
196777
+ return ctx.defaults.get(attr.value.expr.trim()) ?? attr.value.expr;
196737
196778
  case "template":
196738
- return resolveTemplateAttr(attr.value);
196779
+ return resolveTemplateAttr(attr.value, ctx);
196739
196780
  case "boolean-shorthand":
196740
196781
  return true;
196741
196782
  case "jsx-children":
196742
196783
  return null;
196743
196784
  }
196744
196785
  }
196745
- function resolveTemplateAttr(tl) {
196786
+ function resolveTemplateAttr(tl, ctx) {
196746
196787
  return tl.parts.map((part) => {
196747
196788
  if (part.type === "string")
196748
- return part.value;
196789
+ return substituteInterpolations(part.value, ctx);
196749
196790
  if (part.type === "ternary")
196750
196791
  return `{${part.condition}}`;
196751
196792
  return Object.values(part.cases).join(" ");
@@ -1 +1 @@
1
- {"version":3,"file":"ir-to-test-node.d.ts","sourceRoot":"","sources":["../src/ir-to-test-node.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EACV,MAAM,EAYN,UAAU,EACX,MAAM,iBAAiB,CAAA;AAMxB,OAAO,EAAE,QAAQ,EAAqB,MAAM,gBAAgB,CAAA;AAQ5D,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,QAAQ,CAAC,EAAE,UAAU,GAAG,QAAQ,CAajH"}
1
+ {"version":3,"file":"ir-to-test-node.d.ts","sourceRoot":"","sources":["../src/ir-to-test-node.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EACV,MAAM,EAYN,UAAU,EACX,MAAM,iBAAiB,CAAA;AAMxB,OAAO,EAAE,QAAQ,EAAqB,MAAM,gBAAgB,CAAA;AAU5D,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,QAAQ,CAAC,EAAE,UAAU,GAAG,QAAQ,CAuBjH"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/test",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Test utilities for BarefootJS - IR-based component testing without a browser",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -39,7 +39,7 @@
39
39
  "directory": "packages/test"
40
40
  },
41
41
  "dependencies": {
42
- "@barefootjs/jsx": "0.17.0"
42
+ "@barefootjs/jsx": "0.17.1"
43
43
  },
44
44
  "devDependencies": {
45
45
  "typescript": "^5.0.0"
@@ -30,12 +30,15 @@ interface ConvertContext {
30
30
  cmap: Map<string, string>
31
31
  setterToSignal: Map<string, string>
32
32
  fnSetters: Map<string, FnSetterResolution[]>
33
+ /** Prop name → literal destructure-default value (`{ size = 'md' }` → `size: 'md'`). */
34
+ defaults: Map<string, string>
33
35
  }
34
36
 
35
37
  export function irNodeToTestNode(node: IRNode, constantMap?: Map<string, string>, metadata?: IRMetadata): TestNode {
36
38
  const cmap = constantMap ?? new Map<string, string>()
37
39
  const setterToSignal = new Map<string, string>()
38
40
  const fnSetters = new Map<string, FnSetterResolution[]>()
41
+ const defaults = new Map<string, string>()
39
42
  if (metadata) {
40
43
  for (const s of metadata.signals) {
41
44
  if (s.setter) setterToSignal.set(s.setter, s.getter)
@@ -43,8 +46,35 @@ export function irNodeToTestNode(node: IRNode, constantMap?: Map<string, string>
43
46
  for (const [k, v] of buildLocalFunctionSetterMap(metadata, setterToSignal)) {
44
47
  fnSetters.set(k, v)
45
48
  }
49
+ // renderToTest models the component compiled with NO incoming props,
50
+ // so a literal destructure default IS the statically-known value of
51
+ // that prop (#2069). Non-literal defaults (arrows, objects, computed
52
+ // expressions) stay unresolved — surfacing a stale expression string
53
+ // would be worse than the raw reference.
54
+ for (const p of metadata.propsParams) {
55
+ const lit = p.defaultValue !== undefined ? parseLiteralDefault(p.defaultValue) : null
56
+ if (lit !== null) defaults.set(p.name, lit)
57
+ }
46
58
  }
47
- return convert(node, { cmap, setterToSignal, fnSetters })
59
+ return convert(node, { cmap, setterToSignal, fnSetters, defaults })
60
+ }
61
+
62
+ /**
63
+ * Parse a `ParamInfo.defaultValue` JS-text into a plain string when it is
64
+ * a self-contained literal: quoted string, number, or boolean. Returns
65
+ * null for anything else.
66
+ */
67
+ function parseLiteralDefault(js: string): string | null {
68
+ const v = js.trim()
69
+ if (
70
+ (v.startsWith("'") && v.endsWith("'") && !v.slice(1, -1).includes("'")) ||
71
+ (v.startsWith('"') && v.endsWith('"') && !v.slice(1, -1).includes('"'))
72
+ ) {
73
+ return v.slice(1, -1)
74
+ }
75
+ if (/^-?\d+(\.\d+)?$/.test(v)) return v
76
+ if (v === 'true' || v === 'false') return v
77
+ return null
48
78
  }
49
79
 
50
80
  function convert(node: IRNode, ctx: ConvertContext): TestNode {
@@ -54,7 +84,7 @@ function convert(node: IRNode, ctx: ConvertContext): TestNode {
54
84
  case 'text':
55
85
  return convertText(node)
56
86
  case 'expression':
57
- return convertExpression(node)
87
+ return convertExpression(node, ctx)
58
88
  case 'conditional':
59
89
  return convertConditional(node, ctx)
60
90
  case 'loop':
@@ -90,20 +120,10 @@ function convertElement(node: IRElement, ctx: ConvertContext): TestNode {
90
120
  let classes: string[] = []
91
121
 
92
122
  for (const attr of node.attrs) {
93
- const value = resolveAttrValue(attr)
123
+ const value = resolveAttrValue(attr, ctx)
94
124
 
95
125
  if (attr.name === 'className' || attr.name === 'class') {
96
- if (typeof value === 'string') {
97
- const isDynamic = attr.value.kind === 'expression' || attr.value.kind === 'template' || attr.value.kind === 'spread'
98
- if (isDynamic && ctx.cmap.size > 0) {
99
- const resolved = resolveClassValue(value, ctx.cmap)
100
- if (resolved !== null) {
101
- classes = resolved.split(/\s+/).filter(Boolean)
102
- continue
103
- }
104
- }
105
- classes = value.split(/\s+/).filter(Boolean)
106
- }
126
+ classes = collectClassTokens(attr.value, value, ctx)
107
127
  continue
108
128
  }
109
129
 
@@ -178,12 +198,20 @@ function convertText(node: IRText): TestNode {
178
198
  // Expression
179
199
  // ---------------------------------------------------------------------------
180
200
 
181
- function convertExpression(node: IRExpression): TestNode {
201
+ function convertExpression(node: IRExpression, ctx: ConvertContext): TestNode {
202
+ // A bare reference to a defaulted prop (`<div>{label}</div>` with
203
+ // `{ label = 'Hello' }`) resolves to its literal default, so
204
+ // `findByText('Hello')` sees the zero-props render (#2069). Prop refs
205
+ // are flagged reactive (they update on parent re-render), so this
206
+ // keys off defaults-map membership, not the reactive flag: signal /
207
+ // memo reads are call expressions (`count()`), never bare identifiers,
208
+ // and keep their source text — wiring is the assertion surface there.
209
+ const text = ctx.defaults.get(node.expr.trim()) ?? node.expr
182
210
  return new TestNode({
183
211
  tag: null,
184
212
  type: 'expression',
185
213
  children: [],
186
- text: node.expr,
214
+ text,
187
215
  props: {},
188
216
  classes: [],
189
217
  role: null,
@@ -265,7 +293,7 @@ function convertComponent(node: IRComponent, ctx: ConvertContext): TestNode {
265
293
  props[prop.name] = prop.value.expr
266
294
  break
267
295
  case 'template':
268
- props[prop.name] = resolveTemplateAttr(prop.value)
296
+ props[prop.name] = resolveTemplateAttr(prop.value, ctx)
269
297
  break
270
298
  case 'boolean-shorthand':
271
299
  case 'boolean-attr':
@@ -450,19 +478,77 @@ function refsToHandler(refs: SetterRef[]): EventHandler {
450
478
  return { setters, via }
451
479
  }
452
480
 
453
- function resolveClassValue(value: string, cmap: Map<string, string>): string | null {
454
- // Simple identifier lookup
455
- if (cmap.has(value)) {
456
- return cmap.get(value)!
481
+ /**
482
+ * Split a resolved className value into tokens, dropping any span that
483
+ * still carries an unresolved runtime interpolation (`${className}`,
484
+ * `foo-${x}`). Those are dynamic passthroughs the IR can't evaluate —
485
+ * they aren't real class tokens, and leaking them verbatim pollutes
486
+ * `.classes` for exact-match assertions.
487
+ */
488
+ function splitClassTokens(value: string): string[] {
489
+ return value.split(/\s+/).filter(t => t && !t.includes('${'))
490
+ }
491
+
492
+ /**
493
+ * Resolve a className attribute value into its class tokens.
494
+ *
495
+ * For a structured template attr the parts are walked directly so class
496
+ * collection keeps union semantics per part kind:
497
+ * - `lookup` (`${MAP[KEY]}`) → every case's tokens (PR #2000)
498
+ * - `ternary` (`cond ? 'on' : 'off'`) → both branches' tokens,
499
+ * matching the intermediate-const `valueBranches` union (#525)
500
+ * - `string` spans → literal text, with `${ident}` interpolations
501
+ * substituted from resolved consts / literal prop defaults
502
+ * For expression attrs the value resolves through local consts (cmap)
503
+ * and literal prop defaults. Anything still carrying a `${...}`
504
+ * interpolation is dropped by `splitClassTokens`.
505
+ */
506
+ function collectClassTokens(attrValue: AttrValue, resolved: string | boolean | null, ctx: ConvertContext): string[] {
507
+ if (attrValue.kind === 'template') {
508
+ const joined = attrValue.parts
509
+ .map(part => {
510
+ if (part.type === 'string') return substituteInterpolations(part.value, ctx)
511
+ if (part.type === 'ternary') return `${part.whenTrue} ${part.whenFalse}`
512
+ return Object.values(part.cases).join(' ')
513
+ })
514
+ .join('')
515
+ return splitClassTokens(joined)
516
+ }
517
+
518
+ if (typeof resolved !== 'string') return []
519
+
520
+ const isDynamic = attrValue.kind === 'expression' || attrValue.kind === 'spread'
521
+ if (isDynamic) {
522
+ const value = resolveClassValue(resolved, ctx)
523
+ if (value !== null) return splitClassTokens(value)
524
+ }
525
+ return splitClassTokens(resolved)
526
+ }
527
+
528
+ /**
529
+ * Replace `${ident}` interpolations with a resolved const or a literal
530
+ * prop default; unresolvable spans are kept verbatim so the caller's
531
+ * token filter can drop them.
532
+ */
533
+ function substituteInterpolations(value: string, ctx: ConvertContext): string {
534
+ return value.replace(/\$\{([^}]+)\}/g, (raw, expr) => {
535
+ const trimmed = expr.trim()
536
+ return ctx.cmap.get(trimmed) ?? ctx.defaults.get(trimmed) ?? raw
537
+ })
538
+ }
539
+
540
+ function resolveClassValue(value: string, ctx: ConvertContext): string | null {
541
+ // Simple identifier lookup: a local const or a literal prop default.
542
+ if (ctx.cmap.has(value)) {
543
+ return ctx.cmap.get(value)!
544
+ }
545
+ if (ctx.defaults.has(value)) {
546
+ return ctx.defaults.get(value)!
457
547
  }
458
548
 
459
549
  // Template literal: `...${var}...`
460
550
  if (value.startsWith('`') && value.endsWith('`')) {
461
- const inner = value.slice(1, -1)
462
- return inner.replace(/\$\{([^}]+)\}/g, (_, expr) => {
463
- const trimmed = expr.trim()
464
- return cmap.get(trimmed) ?? ''
465
- })
551
+ return substituteInterpolations(value.slice(1, -1), ctx)
466
552
  }
467
553
 
468
554
  return null
@@ -472,18 +558,22 @@ function resolveClassValue(value: string, cmap: Map<string, string>): string | n
472
558
  // Attribute value resolution
473
559
  // ---------------------------------------------------------------------------
474
560
 
475
- function resolveAttrValue(attr: IRAttribute): string | boolean | null {
561
+ function resolveAttrValue(attr: IRAttribute, ctx: ConvertContext): string | boolean | null {
476
562
  switch (attr.value.kind) {
477
563
  case 'boolean-attr':
478
564
  return true
479
565
  case 'literal':
480
566
  return attr.value.value
481
567
  case 'expression':
482
- return attr.value.expr
483
568
  case 'spread':
484
- return attr.value.expr
569
+ // A bare reference to a defaulted prop (`type={type}` with
570
+ // `{ type = 'button' }`) resolves to its literal default —
571
+ // renderToTest models the zero-props render, where the default
572
+ // IS the value (#2069). Anything non-bare keeps the expression
573
+ // text (the wiring-visible representation).
574
+ return ctx.defaults.get(attr.value.expr.trim()) ?? attr.value.expr
485
575
  case 'template':
486
- return resolveTemplateAttr(attr.value)
576
+ return resolveTemplateAttr(attr.value, ctx)
487
577
  case 'boolean-shorthand':
488
578
  return true
489
579
  case 'jsx-children':
@@ -491,10 +581,12 @@ function resolveAttrValue(attr: IRAttribute): string | boolean | null {
491
581
  }
492
582
  }
493
583
 
494
- function resolveTemplateAttr(tl: TemplateAttr): string {
584
+ function resolveTemplateAttr(tl: TemplateAttr, ctx: ConvertContext): string {
495
585
  return tl.parts
496
586
  .map(part => {
497
- if (part.type === 'string') return part.value
587
+ // `${ident}` interpolations against a literal prop default
588
+ // resolve like local consts do (#2069).
589
+ if (part.type === 'string') return substituteInterpolations(part.value, ctx)
498
590
  if (part.type === 'ternary') return `{${part.condition}}`
499
591
  // `lookup` (`Record<T, string>[key]`) — the test framework
500
592
  // doesn't render against a specific key, so concatenate every