@cdevhub/ngx-tw 0.8.0 → 0.9.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.
@@ -100,8 +100,8 @@ class CommandPaletteHarness extends ComponentHarness {
100
100
  *
101
101
  * **The overlay is still attached when this resolves.** The component defers
102
102
  * the detach behind a leave animation, so a caller asserting on `isOpen()`
103
- * immediately afterwards will still see `true`. Wait for the animation before
104
- * asserting — with a plain timer, not by polling a harness method.
103
+ * immediately afterwards will still see `true`. Wait for the detach before
104
+ * asserting — by reading the document, never by polling a harness method.
105
105
  *
106
106
  * That caveat is deliberate rather than hidden behind a poll. An earlier
107
107
  * version looped on `isOpen()` until the panel detached, which reads better
@@ -112,11 +112,11 @@ class CommandPaletteHarness extends ComponentHarness {
112
112
  * failing it. A harness that can hang is worse than one that makes the caller
113
113
  * wait explicitly.
114
114
  *
115
- * **This method is not covered by a spec.** A test driving it hung at the full
116
- * 15000ms budget in roughly one run in three: the harness calls it makes
117
- * around a leave animation route through `whenStable()`, which can wait on a
118
- * re-scheduled timer and never resolve. Escape dismissal itself is covered in
119
- * `command-palette.spec.ts`, directly against the component.
115
+ * Wait by polling the document for the panel to leave, under your own
116
+ * deadline `document.querySelector('tw-command-palette-overlay') === null`
117
+ * needs no stabilization, so it can neither hang nor burn a fixed sleep. The
118
+ * `closes with Escape` case in `command-palette-harness.spec.ts` is the
119
+ * worked example, and is this method's coverage.
120
120
  */
121
121
  async close() {
122
122
  const input = await this.input();
@@ -1 +1 @@
1
- {"version":3,"file":"cdevhub-ngx-tw-command-palette-testing.mjs","sources":["../../../projects/ngx-tw/command-palette/testing/command-palette-item-harness.ts","../../../projects/ngx-tw/command-palette/testing/command-palette-harness.ts","../../../projects/ngx-tw/command-palette/testing/cdevhub-ngx-tw-command-palette-testing.ts"],"sourcesContent":["import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';\nimport type { BaseHarnessFilters } from '@angular/cdk/testing';\n\n/** Filters accepted by `CommandPaletteItemHarness.with`. */\nexport interface CommandPaletteItemHarnessFilters extends BaseHarnessFilters {\n /** Match by the item's visible text, which includes its description when it has one. */\n text?: string | RegExp;\n /** Match the active (`aria-activedescendant`) item. */\n active?: boolean;\n /** Match disabled / enabled items. */\n disabled?: boolean;\n}\n\n/**\n * Harness for a single result row in an open `tw-command-palette`.\n *\n * These rows are `role=\"option\"` inside an activedescendant listbox: they never\n * receive DOM focus and have no keyboard handlers of their own. \"Active\" here\n * therefore means *referenced by the input's `aria-activedescendant`*, which the\n * component mirrors onto each row as `aria-selected`. It does **not** mean\n * `document.activeElement`.\n */\nexport class CommandPaletteItemHarness extends ComponentHarness {\n static hostSelector = '[role=\"option\"]';\n\n /** Predicate for `locatorFor` / `locatorForAll`. */\n static with(\n options: CommandPaletteItemHarnessFilters = {},\n ): HarnessPredicate<CommandPaletteItemHarness> {\n return new HarnessPredicate(CommandPaletteItemHarness, options)\n .addOption('text', options.text, async (harness, text) =>\n HarnessPredicate.stringMatches(await harness.getText(), text),\n )\n .addOption(\n 'active',\n options.active,\n async (harness, active) => (await harness.isActive()) === active,\n )\n .addOption(\n 'disabled',\n options.disabled,\n async (harness, disabled) => (await harness.isDisabled()) === disabled,\n );\n }\n\n /** The row's rendered text, trimmed. Includes the description when one is rendered. */\n async getText(): Promise<string> {\n return (await (await this.host()).text()).trim();\n }\n\n /** The row's DOM id, which is what `aria-activedescendant` points at. */\n async getId(): Promise<string | null> {\n return (await this.host()).getAttribute('id');\n }\n\n /**\n * Whether this row is the active descendant. Read from `aria-selected`, which\n * the component binds to the active id — not from DOM focus, which never moves\n * off the search input.\n */\n async isActive(): Promise<boolean> {\n return (await (await this.host()).getAttribute('aria-selected')) === 'true';\n }\n\n /** Whether the row reports `aria-disabled=\"true\"`. */\n async isDisabled(): Promise<boolean> {\n return (await (await this.host()).getAttribute('aria-disabled')) === 'true';\n }\n\n /** Clicks the row, activating the command. */\n async click(): Promise<void> {\n await (await this.host()).click();\n }\n}\n","import { ComponentHarness, HarnessPredicate, TestKey } from '@angular/cdk/testing';\nimport type { BaseHarnessFilters } from '@angular/cdk/testing';\nimport {\n CommandPaletteItemHarness,\n type CommandPaletteItemHarnessFilters,\n} from './command-palette-item-harness';\n\n/** Filters accepted by `CommandPaletteHarness.with`. */\nexport interface CommandPaletteHarnessFilters extends BaseHarnessFilters {\n /** Match by the palette's accessible label. */\n label?: string | RegExp;\n}\n\n/**\n * Harness for `tw-command-palette`.\n *\n * The palette renders into the CDK overlay container, outside the\n * `tw-command-palette` host, so this harness resolves the panel through\n * `documentRootLocatorFactory()`. A consumer loads it from the ordinary fixture\n * loader and still reaches the results.\n *\n * The palette is an **activedescendant listbox**: DOM focus stays on the search\n * input and the active row is identified only by `aria-activedescendant`. Every\n * \"active\" method here resolves that id reference — none of them consults\n * `document.activeElement`, which would always report the input.\n */\nexport class CommandPaletteHarness extends ComponentHarness {\n static hostSelector = 'tw-command-palette';\n\n private readonly panel =\n this.documentRootLocatorFactory().locatorForOptional('[role=\"dialog\"]');\n private readonly input =\n this.documentRootLocatorFactory().locatorForOptional('input[role=\"combobox\"]');\n\n /** Predicate for `locatorFor` / `locatorForAll`. */\n static with(\n options: CommandPaletteHarnessFilters = {},\n ): HarnessPredicate<CommandPaletteHarness> {\n return new HarnessPredicate(CommandPaletteHarness, options).addOption(\n 'label',\n options.label,\n async (harness, label) =>\n HarnessPredicate.stringMatches(await harness.getLabel(), label),\n );\n }\n\n /** Whether the palette overlay is currently attached. */\n async isOpen(): Promise<boolean> {\n return (await this.panel()) !== null;\n }\n\n /** The palette's accessible label, or `null` when it is closed. */\n async getLabel(): Promise<string | null> {\n const panel = await this.panel();\n return panel ? panel.getAttribute('aria-label') : null;\n }\n\n /** The current search query. Returns an empty string when the palette is closed. */\n async getQuery(): Promise<string> {\n const input = await this.input();\n return input ? input.getProperty<string>('value') : '';\n }\n\n /**\n * Types into the search input, replacing any existing query, then waits for\n * the results to settle.\n */\n async setQuery(query: string): Promise<void> {\n const input = await this.requireInput('setQuery');\n await input.clear();\n await input.sendKeys(query);\n await this.waitForTasksOutsideAngular();\n }\n\n /** Clears the search query. */\n async clearQuery(): Promise<void> {\n const input = await this.requireInput('clearQuery');\n await input.clear();\n await this.waitForTasksOutsideAngular();\n }\n\n /**\n * Sends Escape to the search input. No-op when the palette is already closed.\n *\n * **The overlay is still attached when this resolves.** The component defers\n * the detach behind a leave animation, so a caller asserting on `isOpen()`\n * immediately afterwards will still see `true`. Wait for the animation before\n * asserting — with a plain timer, not by polling a harness method.\n *\n * That caveat is deliberate rather than hidden behind a poll. An earlier\n * version looped on `isOpen()` until the panel detached, which reads better\n * but is unsound here: every harness call routes through\n * `fixture.whenStable()`, and under zoneless that can wait on a re-scheduled\n * timer and never resolve. A deadline checked *between* awaits cannot bound a\n * single await that never returns, so the loop hung the suite instead of\n * failing it. A harness that can hang is worse than one that makes the caller\n * wait explicitly.\n *\n * **This method is not covered by a spec.** A test driving it hung at the full\n * 15000ms budget in roughly one run in three: the harness calls it makes\n * around a leave animation route through `whenStable()`, which can wait on a\n * re-scheduled timer and never resolve. Escape dismissal itself is covered in\n * `command-palette.spec.ts`, directly against the component.\n */\n async close(): Promise<void> {\n const input = await this.input();\n if (!input) return;\n await input.sendKeys(TestKey.ESCAPE);\n }\n\n /** Moves the active descendant down one row. */\n async pressArrowDown(): Promise<void> {\n await (await this.requireInput('pressArrowDown')).sendKeys(TestKey.DOWN_ARROW);\n await this.waitForTasksOutsideAngular();\n }\n\n /** Moves the active descendant up one row. */\n async pressArrowUp(): Promise<void> {\n await (await this.requireInput('pressArrowUp')).sendKeys(TestKey.UP_ARROW);\n await this.waitForTasksOutsideAngular();\n }\n\n /** Activates the current active descendant with Enter. */\n async pressEnter(): Promise<void> {\n await (await this.requireInput('pressEnter')).sendKeys(TestKey.ENTER);\n await this.waitForTasksOutsideAngular();\n }\n\n /**\n * Every result row currently rendered. Returns an empty array when the palette\n * is closed.\n */\n async getItems(\n filters: CommandPaletteItemHarnessFilters = {},\n ): Promise<CommandPaletteItemHarness[]> {\n if (!(await this.panel())) return [];\n return this.documentRootLocatorFactory().locatorForAll(\n CommandPaletteItemHarness.with(filters),\n )();\n }\n\n /**\n * The labels of the rendered group headings, in DOM order. Ungrouped results\n * produce an empty array.\n */\n async getGroupLabels(): Promise<string[]> {\n if (!(await this.panel())) return [];\n const groups = await this.documentRootLocatorFactory().locatorForAll(\n '[role=\"group\"]',\n )();\n const labels = await Promise.all(\n groups.map((group) => group.getAttribute('aria-label')),\n );\n return labels.filter((label): label is string => label !== null);\n }\n\n /**\n * The text of the active row, resolved through the input's\n * `aria-activedescendant`, or `null` when nothing is active.\n */\n async getActiveItemText(): Promise<string | null> {\n const active = await this.getItems({ active: true });\n return active.length > 0 ? active[0].getText() : null;\n }\n\n /**\n * Clicks the first row whose text matches. Throws when nothing matches, rather\n * than failing silently.\n */\n async selectItem(text: string | RegExp): Promise<void> {\n const matches = await this.getItems({ text });\n if (matches.length === 0) {\n throw new Error(\n `CommandPaletteHarness.selectItem: no result matching ${String(text)}.`,\n );\n }\n await matches[0].click();\n await this.waitForTasksOutsideAngular();\n }\n\n /**\n * The search input, or a named error when the palette is closed. Every typing\n * and key method needs it, and \"cannot read property of null\" would not say why.\n */\n private async requireInput(method: string) {\n const input = await this.input();\n if (!input) {\n throw new Error(\n `CommandPaletteHarness.${method}: the palette is closed, so it has no search input.`,\n );\n }\n return input;\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cdevhub-ngx-tw-command-palette-testing.mjs","sources":["../../../projects/ngx-tw/command-palette/testing/command-palette-item-harness.ts","../../../projects/ngx-tw/command-palette/testing/command-palette-harness.ts","../../../projects/ngx-tw/command-palette/testing/cdevhub-ngx-tw-command-palette-testing.ts"],"sourcesContent":["import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';\nimport type { BaseHarnessFilters } from '@angular/cdk/testing';\n\n/** Filters accepted by `CommandPaletteItemHarness.with`. */\nexport interface CommandPaletteItemHarnessFilters extends BaseHarnessFilters {\n /** Match by the item's visible text, which includes its description when it has one. */\n text?: string | RegExp;\n /** Match the active (`aria-activedescendant`) item. */\n active?: boolean;\n /** Match disabled / enabled items. */\n disabled?: boolean;\n}\n\n/**\n * Harness for a single result row in an open `tw-command-palette`.\n *\n * These rows are `role=\"option\"` inside an activedescendant listbox: they never\n * receive DOM focus and have no keyboard handlers of their own. \"Active\" here\n * therefore means *referenced by the input's `aria-activedescendant`*, which the\n * component mirrors onto each row as `aria-selected`. It does **not** mean\n * `document.activeElement`.\n */\nexport class CommandPaletteItemHarness extends ComponentHarness {\n static hostSelector = '[role=\"option\"]';\n\n /** Predicate for `locatorFor` / `locatorForAll`. */\n static with(\n options: CommandPaletteItemHarnessFilters = {},\n ): HarnessPredicate<CommandPaletteItemHarness> {\n return new HarnessPredicate(CommandPaletteItemHarness, options)\n .addOption('text', options.text, async (harness, text) =>\n HarnessPredicate.stringMatches(await harness.getText(), text),\n )\n .addOption(\n 'active',\n options.active,\n async (harness, active) => (await harness.isActive()) === active,\n )\n .addOption(\n 'disabled',\n options.disabled,\n async (harness, disabled) => (await harness.isDisabled()) === disabled,\n );\n }\n\n /** The row's rendered text, trimmed. Includes the description when one is rendered. */\n async getText(): Promise<string> {\n return (await (await this.host()).text()).trim();\n }\n\n /** The row's DOM id, which is what `aria-activedescendant` points at. */\n async getId(): Promise<string | null> {\n return (await this.host()).getAttribute('id');\n }\n\n /**\n * Whether this row is the active descendant. Read from `aria-selected`, which\n * the component binds to the active id — not from DOM focus, which never moves\n * off the search input.\n */\n async isActive(): Promise<boolean> {\n return (await (await this.host()).getAttribute('aria-selected')) === 'true';\n }\n\n /** Whether the row reports `aria-disabled=\"true\"`. */\n async isDisabled(): Promise<boolean> {\n return (await (await this.host()).getAttribute('aria-disabled')) === 'true';\n }\n\n /** Clicks the row, activating the command. */\n async click(): Promise<void> {\n await (await this.host()).click();\n }\n}\n","import { ComponentHarness, HarnessPredicate, TestKey } from '@angular/cdk/testing';\nimport type { BaseHarnessFilters } from '@angular/cdk/testing';\nimport {\n CommandPaletteItemHarness,\n type CommandPaletteItemHarnessFilters,\n} from './command-palette-item-harness';\n\n/** Filters accepted by `CommandPaletteHarness.with`. */\nexport interface CommandPaletteHarnessFilters extends BaseHarnessFilters {\n /** Match by the palette's accessible label. */\n label?: string | RegExp;\n}\n\n/**\n * Harness for `tw-command-palette`.\n *\n * The palette renders into the CDK overlay container, outside the\n * `tw-command-palette` host, so this harness resolves the panel through\n * `documentRootLocatorFactory()`. A consumer loads it from the ordinary fixture\n * loader and still reaches the results.\n *\n * The palette is an **activedescendant listbox**: DOM focus stays on the search\n * input and the active row is identified only by `aria-activedescendant`. Every\n * \"active\" method here resolves that id reference — none of them consults\n * `document.activeElement`, which would always report the input.\n */\nexport class CommandPaletteHarness extends ComponentHarness {\n static hostSelector = 'tw-command-palette';\n\n private readonly panel =\n this.documentRootLocatorFactory().locatorForOptional('[role=\"dialog\"]');\n private readonly input =\n this.documentRootLocatorFactory().locatorForOptional('input[role=\"combobox\"]');\n\n /** Predicate for `locatorFor` / `locatorForAll`. */\n static with(\n options: CommandPaletteHarnessFilters = {},\n ): HarnessPredicate<CommandPaletteHarness> {\n return new HarnessPredicate(CommandPaletteHarness, options).addOption(\n 'label',\n options.label,\n async (harness, label) =>\n HarnessPredicate.stringMatches(await harness.getLabel(), label),\n );\n }\n\n /** Whether the palette overlay is currently attached. */\n async isOpen(): Promise<boolean> {\n return (await this.panel()) !== null;\n }\n\n /** The palette's accessible label, or `null` when it is closed. */\n async getLabel(): Promise<string | null> {\n const panel = await this.panel();\n return panel ? panel.getAttribute('aria-label') : null;\n }\n\n /** The current search query. Returns an empty string when the palette is closed. */\n async getQuery(): Promise<string> {\n const input = await this.input();\n return input ? input.getProperty<string>('value') : '';\n }\n\n /**\n * Types into the search input, replacing any existing query, then waits for\n * the results to settle.\n */\n async setQuery(query: string): Promise<void> {\n const input = await this.requireInput('setQuery');\n await input.clear();\n await input.sendKeys(query);\n await this.waitForTasksOutsideAngular();\n }\n\n /** Clears the search query. */\n async clearQuery(): Promise<void> {\n const input = await this.requireInput('clearQuery');\n await input.clear();\n await this.waitForTasksOutsideAngular();\n }\n\n /**\n * Sends Escape to the search input. No-op when the palette is already closed.\n *\n * **The overlay is still attached when this resolves.** The component defers\n * the detach behind a leave animation, so a caller asserting on `isOpen()`\n * immediately afterwards will still see `true`. Wait for the detach before\n * asserting — by reading the document, never by polling a harness method.\n *\n * That caveat is deliberate rather than hidden behind a poll. An earlier\n * version looped on `isOpen()` until the panel detached, which reads better\n * but is unsound here: every harness call routes through\n * `fixture.whenStable()`, and under zoneless that can wait on a re-scheduled\n * timer and never resolve. A deadline checked *between* awaits cannot bound a\n * single await that never returns, so the loop hung the suite instead of\n * failing it. A harness that can hang is worse than one that makes the caller\n * wait explicitly.\n *\n * Wait by polling the document for the panel to leave, under your own\n * deadline — `document.querySelector('tw-command-palette-overlay') === null`\n * needs no stabilization, so it can neither hang nor burn a fixed sleep. The\n * `closes with Escape` case in `command-palette-harness.spec.ts` is the\n * worked example, and is this method's coverage.\n */\n async close(): Promise<void> {\n const input = await this.input();\n if (!input) return;\n await input.sendKeys(TestKey.ESCAPE);\n }\n\n /** Moves the active descendant down one row. */\n async pressArrowDown(): Promise<void> {\n await (await this.requireInput('pressArrowDown')).sendKeys(TestKey.DOWN_ARROW);\n await this.waitForTasksOutsideAngular();\n }\n\n /** Moves the active descendant up one row. */\n async pressArrowUp(): Promise<void> {\n await (await this.requireInput('pressArrowUp')).sendKeys(TestKey.UP_ARROW);\n await this.waitForTasksOutsideAngular();\n }\n\n /** Activates the current active descendant with Enter. */\n async pressEnter(): Promise<void> {\n await (await this.requireInput('pressEnter')).sendKeys(TestKey.ENTER);\n await this.waitForTasksOutsideAngular();\n }\n\n /**\n * Every result row currently rendered. Returns an empty array when the palette\n * is closed.\n */\n async getItems(\n filters: CommandPaletteItemHarnessFilters = {},\n ): Promise<CommandPaletteItemHarness[]> {\n if (!(await this.panel())) return [];\n return this.documentRootLocatorFactory().locatorForAll(\n CommandPaletteItemHarness.with(filters),\n )();\n }\n\n /**\n * The labels of the rendered group headings, in DOM order. Ungrouped results\n * produce an empty array.\n */\n async getGroupLabels(): Promise<string[]> {\n if (!(await this.panel())) return [];\n const groups = await this.documentRootLocatorFactory().locatorForAll(\n '[role=\"group\"]',\n )();\n const labels = await Promise.all(\n groups.map((group) => group.getAttribute('aria-label')),\n );\n return labels.filter((label): label is string => label !== null);\n }\n\n /**\n * The text of the active row, resolved through the input's\n * `aria-activedescendant`, or `null` when nothing is active.\n */\n async getActiveItemText(): Promise<string | null> {\n const active = await this.getItems({ active: true });\n return active.length > 0 ? active[0].getText() : null;\n }\n\n /**\n * Clicks the first row whose text matches. Throws when nothing matches, rather\n * than failing silently.\n */\n async selectItem(text: string | RegExp): Promise<void> {\n const matches = await this.getItems({ text });\n if (matches.length === 0) {\n throw new Error(\n `CommandPaletteHarness.selectItem: no result matching ${String(text)}.`,\n );\n }\n await matches[0].click();\n await this.waitForTasksOutsideAngular();\n }\n\n /**\n * The search input, or a named error when the palette is closed. Every typing\n * and key method needs it, and \"cannot read property of null\" would not say why.\n */\n private async requireInput(method: string) {\n const input = await this.input();\n if (!input) {\n throw new Error(\n `CommandPaletteHarness.${method}: the palette is closed, so it has no search input.`,\n );\n }\n return input;\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":";;AAaA;;;;;;;;AAQG;AACG,MAAO,yBAA0B,SAAQ,gBAAgB,CAAA;AAC7D,IAAA,OAAO,YAAY,GAAG,iBAAiB;;AAGvC,IAAA,OAAO,IAAI,CACT,OAAA,GAA4C,EAAE,EAAA;AAE9C,QAAA,OAAO,IAAI,gBAAgB,CAAC,yBAAyB,EAAE,OAAO;aAC3D,SAAS,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,EAAE,OAAO,OAAO,EAAE,IAAI,KACnD,gBAAgB,CAAC,aAAa,CAAC,MAAM,OAAO,CAAC,OAAO,EAAE,EAAE,IAAI,CAAC;aAE9D,SAAS,CACR,QAAQ,EACR,OAAO,CAAC,MAAM,EACd,OAAO,OAAO,EAAE,MAAM,KAAK,CAAC,MAAM,OAAO,CAAC,QAAQ,EAAE,MAAM,MAAM;aAEjE,SAAS,CACR,UAAU,EACV,OAAO,CAAC,QAAQ,EAChB,OAAO,OAAO,EAAE,QAAQ,KAAK,CAAC,MAAM,OAAO,CAAC,UAAU,EAAE,MAAM,QAAQ,CACvE;IACL;;AAGA,IAAA,MAAM,OAAO,GAAA;AACX,QAAA,OAAO,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE;IAClD;;AAGA,IAAA,MAAM,KAAK,GAAA;AACT,QAAA,OAAO,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,YAAY,CAAC,IAAI,CAAC;IAC/C;AAEA;;;;AAIG;AACH,IAAA,MAAM,QAAQ,GAAA;AACZ,QAAA,OAAO,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,YAAY,CAAC,eAAe,CAAC,MAAM,MAAM;IAC7E;;AAGA,IAAA,MAAM,UAAU,GAAA;AACd,QAAA,OAAO,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,YAAY,CAAC,eAAe,CAAC,MAAM,MAAM;IAC7E;;AAGA,IAAA,MAAM,KAAK,GAAA;QACT,MAAM,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE;IACnC;;;AC3DF;;;;;;;;;;;;AAYG;AACG,MAAO,qBAAsB,SAAQ,gBAAgB,CAAA;AACzD,IAAA,OAAO,YAAY,GAAG,oBAAoB;IAEzB,KAAK,GACpB,IAAI,CAAC,0BAA0B,EAAE,CAAC,kBAAkB,CAAC,iBAAiB,CAAC;IACxD,KAAK,GACpB,IAAI,CAAC,0BAA0B,EAAE,CAAC,kBAAkB,CAAC,wBAAwB,CAAC;;AAGhF,IAAA,OAAO,IAAI,CACT,OAAA,GAAwC,EAAE,EAAA;AAE1C,QAAA,OAAO,IAAI,gBAAgB,CAAC,qBAAqB,EAAE,OAAO,CAAC,CAAC,SAAS,CACnE,OAAO,EACP,OAAO,CAAC,KAAK,EACb,OAAO,OAAO,EAAE,KAAK,KACnB,gBAAgB,CAAC,aAAa,CAAC,MAAM,OAAO,CAAC,QAAQ,EAAE,EAAE,KAAK,CAAC,CAClE;IACH;;AAGA,IAAA,MAAM,MAAM,GAAA;QACV,OAAO,CAAC,MAAM,IAAI,CAAC,KAAK,EAAE,MAAM,IAAI;IACtC;;AAGA,IAAA,MAAM,QAAQ,GAAA;AACZ,QAAA,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,KAAK,EAAE;AAChC,QAAA,OAAO,KAAK,GAAG,KAAK,CAAC,YAAY,CAAC,YAAY,CAAC,GAAG,IAAI;IACxD;;AAGA,IAAA,MAAM,QAAQ,GAAA;AACZ,QAAA,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,KAAK,EAAE;AAChC,QAAA,OAAO,KAAK,GAAG,KAAK,CAAC,WAAW,CAAS,OAAO,CAAC,GAAG,EAAE;IACxD;AAEA;;;AAGG;IACH,MAAM,QAAQ,CAAC,KAAa,EAAA;QAC1B,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,UAAU,CAAC;AACjD,QAAA,MAAM,KAAK,CAAC,KAAK,EAAE;AACnB,QAAA,MAAM,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC;AAC3B,QAAA,MAAM,IAAI,CAAC,0BAA0B,EAAE;IACzC;;AAGA,IAAA,MAAM,UAAU,GAAA;QACd,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,YAAY,CAAC;AACnD,QAAA,MAAM,KAAK,CAAC,KAAK,EAAE;AACnB,QAAA,MAAM,IAAI,CAAC,0BAA0B,EAAE;IACzC;AAEA;;;;;;;;;;;;;;;;;;;;;;AAsBG;AACH,IAAA,MAAM,KAAK,GAAA;AACT,QAAA,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,KAAK,EAAE;AAChC,QAAA,IAAI,CAAC,KAAK;YAAE;QACZ,MAAM,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC;IACtC;;AAGA,IAAA,MAAM,cAAc,GAAA;AAClB,QAAA,MAAM,CAAC,MAAM,IAAI,CAAC,YAAY,CAAC,gBAAgB,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,UAAU,CAAC;AAC9E,QAAA,MAAM,IAAI,CAAC,0BAA0B,EAAE;IACzC;;AAGA,IAAA,MAAM,YAAY,GAAA;AAChB,QAAA,MAAM,CAAC,MAAM,IAAI,CAAC,YAAY,CAAC,cAAc,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC;AAC1E,QAAA,MAAM,IAAI,CAAC,0BAA0B,EAAE;IACzC;;AAGA,IAAA,MAAM,UAAU,GAAA;AACd,QAAA,MAAM,CAAC,MAAM,IAAI,CAAC,YAAY,CAAC,YAAY,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,KAAK,CAAC;AACrE,QAAA,MAAM,IAAI,CAAC,0BAA0B,EAAE;IACzC;AAEA;;;AAGG;AACH,IAAA,MAAM,QAAQ,CACZ,OAAA,GAA4C,EAAE,EAAA;AAE9C,QAAA,IAAI,EAAE,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC;AAAE,YAAA,OAAO,EAAE;AACpC,QAAA,OAAO,IAAI,CAAC,0BAA0B,EAAE,CAAC,aAAa,CACpD,yBAAyB,CAAC,IAAI,CAAC,OAAO,CAAC,CACxC,EAAE;IACL;AAEA;;;AAGG;AACH,IAAA,MAAM,cAAc,GAAA;AAClB,QAAA,IAAI,EAAE,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC;AAAE,YAAA,OAAO,EAAE;AACpC,QAAA,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,0BAA0B,EAAE,CAAC,aAAa,CAClE,gBAAgB,CACjB,EAAE;QACH,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,GAAG,CAC9B,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,KAAK,KAAK,CAAC,YAAY,CAAC,YAAY,CAAC,CAAC,CACxD;AACD,QAAA,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,KAAK,KAAsB,KAAK,KAAK,IAAI,CAAC;IAClE;AAEA;;;AAGG;AACH,IAAA,MAAM,iBAAiB,GAAA;AACrB,QAAA,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;AACpD,QAAA,OAAO,MAAM,CAAC,MAAM,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,GAAG,IAAI;IACvD;AAEA;;;AAGG;IACH,MAAM,UAAU,CAAC,IAAqB,EAAA;QACpC,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,EAAE,IAAI,EAAE,CAAC;AAC7C,QAAA,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE;YACxB,MAAM,IAAI,KAAK,CACb,CAAA,qDAAA,EAAwD,MAAM,CAAC,IAAI,CAAC,CAAA,CAAA,CAAG,CACxE;QACH;AACA,QAAA,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE;AACxB,QAAA,MAAM,IAAI,CAAC,0BAA0B,EAAE;IACzC;AAEA;;;AAGG;IACK,MAAM,YAAY,CAAC,MAAc,EAAA;AACvC,QAAA,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,KAAK,EAAE;QAChC,IAAI,CAAC,KAAK,EAAE;AACV,YAAA,MAAM,IAAI,KAAK,CACb,yBAAyB,MAAM,CAAA,mDAAA,CAAqD,CACrF;QACH;AACA,QAAA,OAAO,KAAK;IACd;;;AChMF;;AAEG;;;;"}
@@ -1,60 +1,8 @@
1
- import { ComponentHarness, manualChangeDetection, HarnessPredicate, TestKey } from '@angular/cdk/testing';
1
+ import { ComponentHarness, HarnessPredicate, TestKey } from '@angular/cdk/testing';
2
2
 
3
- /**
4
- * Lets the zoneless change-detection scheduler run its pending tick.
5
- *
6
- * `ApplicationRef` schedules a tick with `setTimeout(cb)` raced against
7
- * `requestAnimationFrame`. A timer registered *after* the notify that dirtied a
8
- * signal therefore fires *after* that tick, so one macrotask is enough to
9
- * observe everything already scheduled. It is a fixed, bounded yield, not a
10
- * stabilization await, so it cannot hang.
11
- *
12
- * Every method spends one: an action yields after dispatching, so the effects
13
- * the directive applied synchronously are rendered; a read yields before
14
- * looking, so it sees any tick that was already pending. The composition is what
15
- * matters — `open()` returns while the panel's first render is still pending,
16
- * and the following read's own yield is what picks the rendered content up.
17
- */
18
- function afterSchedulerTick() {
19
- return new Promise((resolve) => setTimeout(resolve));
20
- }
21
3
  /**
22
4
  * Harness for a `[twPopover]` trigger and the panel it opens.
23
5
  *
24
- * ## Nothing here awaits application stabilization, and that is load-bearing
25
- *
26
- * `TestbedHarnessEnvironment` routes every `TestElement` operation through
27
- * `forceStabilize()` — `fixture.detectChanges()` then
28
- * `await fixture.whenStable()` — and that await resolves only when Angular's
29
- * `PendingTasks` set is empty. Under full-suite contention it was observed
30
- * **not to resolve at all**, and everything built on it hung for the whole test
31
- * budget instead of failing. This harness was withdrawn twice for that.
32
- *
33
- * Every method body therefore runs inside CDK's `manualChangeDetection()`,
34
- * which sets the flag `forceStabilize()` early-returns on, and so does
35
- * acquisition, via {@link load} / {@link loadAll}. The spec beside this file
36
- * adds the third piece: it never awaits `fixture.whenStable()` either, not even
37
- * in `beforeEach`. All three were needed — each of the two CI failures during
38
- * this restoration was traced to one of them, and the second landed on
39
- * `tooltip` rather than here, which is how it became clear the fault belongs to
40
- * whichever harness spec lands in the unlucky worker slot rather than to any
41
- * one component. `grep -c whenStable` over this file and its spec returns zero,
42
- * which is the whole claim and is checkable in one command rather than by
43
- * counting green runs. The spec pins the rest with tests that hold a real
44
- * `PendingTasks` entry open across acquisition and every method.
45
- *
46
- * Why the application stops stabilizing is **not** known; this removes the
47
- * dependency rather than curing it.
48
- *
49
- * The cost is that change detection is not forced on your behalf. Instead every
50
- * method spends one macrotask on the scheduler (see {@link afterSchedulerTick}),
51
- * which covers everything already scheduled — including the panel's first
52
- * render. What it does not cover is state behind the component's own timers:
53
- * {@link close} dispatches Escape and returns, and the panel detaches only after
54
- * the 120 ms leave window in `popover.ts`. Wait for that by polling the DOM —
55
- * `document.querySelector` needs no stabilization and so can neither hang nor
56
- * burn a fixed interval — and only then read through the harness.
57
- *
58
6
  * ## Loading it
59
7
  *
60
8
  * The host is the trigger, which lives in the fixture, so the ordinary
@@ -70,6 +18,17 @@ function afterSchedulerTick() {
70
18
  * against `aria-haspopup="dialog"` — which the two date-picker triggers also
71
19
  * carry — is needed.
72
20
  *
21
+ * ## Waiting for the panel
22
+ *
23
+ * Every method stabilizes the fixture the way CDK harnesses always do, which
24
+ * covers change detection but **not** the component's own timers: `popover.ts`
25
+ * detaches the panel behind a hard-coded 120 ms leave window driven by a plain
26
+ * `setTimeout`, which Angular's `PendingTasks` does not track, so
27
+ * `whenStable()` does not wait for it. {@link close} therefore dispatches
28
+ * Escape and returns while the panel is still attached. Poll the DOM for its
29
+ * removal — `document.querySelector('tw-popover-overlay')` — and only then read
30
+ * through the harness.
31
+ *
73
32
  * ## The panel is detached, not disposed
74
33
  *
75
34
  * Unlike `tw-select`, closing a popover **detaches** the portal and keeps the
@@ -79,50 +38,17 @@ function afterSchedulerTick() {
79
38
  */
80
39
  class PopoverHarness extends ComponentHarness {
81
40
  static hostSelector = '[data-tw-popover-trigger]';
82
- /**
83
- * Acquires one harness without waiting for the application to stabilize —
84
- * the counterpart to the guarantee the methods below make.
85
- *
86
- * `loader.getHarness(...)` is CDK's own acquisition path and it stabilizes:
87
- * `getAllRawElements` calls `forceStabilize()`, and `HarnessPredicate`
88
- * filtering routes through `parallel()`, which asks *every* active fixture in
89
- * the worker to settle. Both await `fixture.whenStable()`, which is the one
90
- * thing this harness exists to avoid — and the failure that withdrew it was
91
- * observed there, at acquisition, before any method had run.
92
- *
93
- * So acquisition is wrapped too, and `manualChangeDetection()` nests: the
94
- * inner `parallel()` sees the flag already set and skips the stabilization
95
- * entirely. **Render the fixture first** (`fixture.detectChanges()`), because
96
- * nothing here will do it for you; an unrendered fixture fails loudly with
97
- * CDK's "failed to find element" rather than returning something wrong.
98
- *
99
- * Plain `loader.getHarness(PopoverHarness)` still works and is still supported.
100
- * This is the path to use when a suite must not be able to hang.
101
- */
102
- static load(loader, options = {}) {
103
- return manualChangeDetection(() => loader.getHarness(PopoverHarness.with(options)));
104
- }
105
- /** {@link load} for every matching trigger rather than the first. */
106
- static loadAll(loader, options = {}) {
107
- return manualChangeDetection(() => loader.getAllHarnesses(PopoverHarness.with(options)));
108
- }
109
41
  /** Predicate for `locatorFor` / `locatorForAll`. */
110
42
  static with(options = {}) {
111
43
  return new HarnessPredicate(PopoverHarness, options).addOption('triggerText', options.triggerText, async (h, text) => HarnessPredicate.stringMatches(await h.getTriggerText(), text));
112
44
  }
113
45
  /** The text currently rendered in the trigger, trimmed. */
114
46
  async getTriggerText() {
115
- return manualChangeDetection(async () => {
116
- await afterSchedulerTick();
117
- return (await (await this.host()).text()).trim();
118
- });
47
+ return (await (await this.host()).text()).trim();
119
48
  }
120
49
  /** Whether the popover is open, read from the trigger's `aria-expanded`. */
121
50
  async isOpen() {
122
- return manualChangeDetection(async () => {
123
- await afterSchedulerTick();
124
- return (await (await this.host()).getAttribute('aria-expanded')) === 'true';
125
- });
51
+ return (await (await this.host()).getAttribute('aria-expanded')) === 'true';
126
52
  }
127
53
  /**
128
54
  * Opens the popover by clicking the trigger. No-op when already open.
@@ -132,14 +58,10 @@ class PopoverHarness extends ComponentHarness {
132
58
  * own `open()` (reachable via `exportAs: 'twPopover'`), not through a click.
133
59
  */
134
60
  async open() {
135
- await manualChangeDetection(async () => {
136
- await afterSchedulerTick();
137
- const host = await this.host();
138
- if ((await host.getAttribute('aria-expanded')) === 'true')
139
- return;
140
- await host.click();
141
- await afterSchedulerTick();
142
- });
61
+ const host = await this.host();
62
+ if ((await host.getAttribute('aria-expanded')) === 'true')
63
+ return;
64
+ await host.click();
143
65
  }
144
66
  /**
145
67
  * Closes the popover by sending Escape to the trigger — the one dismissal
@@ -150,46 +72,33 @@ class PopoverHarness extends ComponentHarness {
150
72
  * 120 ms leave window; poll the DOM for its removal before asserting.
151
73
  */
152
74
  async close() {
153
- await manualChangeDetection(async () => {
154
- await afterSchedulerTick();
155
- const host = await this.host();
156
- if ((await host.getAttribute('aria-expanded')) !== 'true')
157
- return;
158
- await host.sendKeys(TestKey.ESCAPE);
159
- await afterSchedulerTick();
160
- });
75
+ const host = await this.host();
76
+ if ((await host.getAttribute('aria-expanded')) !== 'true')
77
+ return;
78
+ await host.sendKeys(TestKey.ESCAPE);
161
79
  }
162
80
  /**
163
81
  * Text rendered inside the panel, trimmed, or `null` when the popover is
164
82
  * closed and the panel is detached.
165
83
  */
166
84
  async getText() {
167
- return manualChangeDetection(async () => {
168
- await afterSchedulerTick();
169
- const panel = await this.getPanel();
170
- return panel ? (await panel.text()).trim() : null;
171
- });
85
+ const panel = await this.getPanel();
86
+ return panel ? (await panel.text()).trim() : null;
172
87
  }
173
88
  /**
174
89
  * Whether the panel renders its directional arrow (`twPopoverArrow`). `false`
175
90
  * while the popover is closed, because the panel does not exist then.
176
91
  */
177
92
  async hasArrow() {
178
- return manualChangeDetection(async () => {
179
- await afterSchedulerTick();
180
- const id = await this.getPanelId();
181
- if (!id)
182
- return false;
183
- // The arrow has no dedicated attribute hook: it is the panel wrapper's only
184
- // `aria-hidden` grandchild span, with the content nested one level deeper.
185
- const arrow = await this.documentRootLocatorFactory().locatorForOptional(`#${id} > div > span[aria-hidden="true"]`)();
186
- return arrow !== null;
187
- });
93
+ const id = await this.getPanelId();
94
+ if (!id)
95
+ return false;
96
+ // The arrow has no dedicated attribute hook: it is the panel wrapper's only
97
+ // `aria-hidden` grandchild span, with the content nested one level deeper.
98
+ const arrow = await this.documentRootLocatorFactory().locatorForOptional(`#${id} > div > span[aria-hidden="true"]`)();
99
+ return arrow !== null;
188
100
  }
189
- /**
190
- * The panel element, or `null` when the popover is closed. Callers are already
191
- * inside `manualChangeDetection`.
192
- */
101
+ /** The panel element, or `null` when the popover is closed. */
193
102
  async getPanel() {
194
103
  const id = await this.getPanelId();
195
104
  if (!id)
@@ -1 +1 @@
1
- {"version":3,"file":"cdevhub-ngx-tw-popover-testing.mjs","sources":["../../../projects/ngx-tw/popover/testing/popover-harness.ts","../../../projects/ngx-tw/popover/testing/cdevhub-ngx-tw-popover-testing.ts"],"sourcesContent":["import {\n ComponentHarness,\n HarnessPredicate,\n TestKey,\n manualChangeDetection,\n} from '@angular/cdk/testing';\nimport type { BaseHarnessFilters, HarnessLoader, TestElement } from '@angular/cdk/testing';\n\n/** Filters accepted by `PopoverHarness.with`. */\nexport interface PopoverHarnessFilters extends BaseHarnessFilters {\n /** Match by the text rendered in the trigger. */\n triggerText?: string | RegExp;\n}\n\n/**\n * Lets the zoneless change-detection scheduler run its pending tick.\n *\n * `ApplicationRef` schedules a tick with `setTimeout(cb)` raced against\n * `requestAnimationFrame`. A timer registered *after* the notify that dirtied a\n * signal therefore fires *after* that tick, so one macrotask is enough to\n * observe everything already scheduled. It is a fixed, bounded yield, not a\n * stabilization await, so it cannot hang.\n *\n * Every method spends one: an action yields after dispatching, so the effects\n * the directive applied synchronously are rendered; a read yields before\n * looking, so it sees any tick that was already pending. The composition is what\n * matters — `open()` returns while the panel's first render is still pending,\n * and the following read's own yield is what picks the rendered content up.\n */\nfunction afterSchedulerTick(): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve));\n}\n\n/**\n * Harness for a `[twPopover]` trigger and the panel it opens.\n *\n * ## Nothing here awaits application stabilization, and that is load-bearing\n *\n * `TestbedHarnessEnvironment` routes every `TestElement` operation through\n * `forceStabilize()` — `fixture.detectChanges()` then\n * `await fixture.whenStable()` — and that await resolves only when Angular's\n * `PendingTasks` set is empty. Under full-suite contention it was observed\n * **not to resolve at all**, and everything built on it hung for the whole test\n * budget instead of failing. This harness was withdrawn twice for that.\n *\n * Every method body therefore runs inside CDK's `manualChangeDetection()`,\n * which sets the flag `forceStabilize()` early-returns on, and so does\n * acquisition, via {@link load} / {@link loadAll}. The spec beside this file\n * adds the third piece: it never awaits `fixture.whenStable()` either, not even\n * in `beforeEach`. All three were needed — each of the two CI failures during\n * this restoration was traced to one of them, and the second landed on\n * `tooltip` rather than here, which is how it became clear the fault belongs to\n * whichever harness spec lands in the unlucky worker slot rather than to any\n * one component. `grep -c whenStable` over this file and its spec returns zero,\n * which is the whole claim and is checkable in one command rather than by\n * counting green runs. The spec pins the rest with tests that hold a real\n * `PendingTasks` entry open across acquisition and every method.\n *\n * Why the application stops stabilizing is **not** known; this removes the\n * dependency rather than curing it.\n *\n * The cost is that change detection is not forced on your behalf. Instead every\n * method spends one macrotask on the scheduler (see {@link afterSchedulerTick}),\n * which covers everything already scheduled — including the panel's first\n * render. What it does not cover is state behind the component's own timers:\n * {@link close} dispatches Escape and returns, and the panel detaches only after\n * the 120 ms leave window in `popover.ts`. Wait for that by polling the DOM —\n * `document.querySelector` needs no stabilization and so can neither hang nor\n * burn a fixed interval — and only then read through the harness.\n *\n * ## Loading it\n *\n * The host is the trigger, which lives in the fixture, so the ordinary\n * `TestbedHarnessEnvironment.loader(fixture)` is correct. The panel renders into\n * the CDK overlay container outside the fixture, and this harness resolves it\n * internally via `documentRootLocatorFactory()` — a consumer never needs\n * `documentRootLoader`.\n *\n * The host selector is the directive's static `data-tw-popover-trigger` marker.\n * `[twPopover]` cannot be used: it takes a required `TemplateRef` or component\n * type, so it is always property-bound and Angular renders no attribute for a\n * bound input. The marker also makes the match exact, so no disambiguation\n * against `aria-haspopup=\"dialog\"` — which the two date-picker triggers also\n * carry — is needed.\n *\n * ## The panel is detached, not disposed\n *\n * Unlike `tw-select`, closing a popover **detaches** the portal and keeps the\n * `OverlayRef` for reuse; it is only rebuilt when `twPopoverBackdrop` or\n * `twPopoverScrollStrategy` changes. The panel element is therefore absent while\n * closed and present again after a reopen, on the same overlay.\n */\nexport class PopoverHarness extends ComponentHarness {\n static hostSelector = '[data-tw-popover-trigger]';\n\n /**\n * Acquires one harness without waiting for the application to stabilize —\n * the counterpart to the guarantee the methods below make.\n *\n * `loader.getHarness(...)` is CDK's own acquisition path and it stabilizes:\n * `getAllRawElements` calls `forceStabilize()`, and `HarnessPredicate`\n * filtering routes through `parallel()`, which asks *every* active fixture in\n * the worker to settle. Both await `fixture.whenStable()`, which is the one\n * thing this harness exists to avoid — and the failure that withdrew it was\n * observed there, at acquisition, before any method had run.\n *\n * So acquisition is wrapped too, and `manualChangeDetection()` nests: the\n * inner `parallel()` sees the flag already set and skips the stabilization\n * entirely. **Render the fixture first** (`fixture.detectChanges()`), because\n * nothing here will do it for you; an unrendered fixture fails loudly with\n * CDK's \"failed to find element\" rather than returning something wrong.\n *\n * Plain `loader.getHarness(PopoverHarness)` still works and is still supported.\n * This is the path to use when a suite must not be able to hang.\n */\n static load(loader: HarnessLoader, options: PopoverHarnessFilters = {}): Promise<PopoverHarness> {\n return manualChangeDetection(() => loader.getHarness(PopoverHarness.with(options)));\n }\n\n /** {@link load} for every matching trigger rather than the first. */\n static loadAll(\n loader: HarnessLoader,\n options: PopoverHarnessFilters = {},\n ): Promise<PopoverHarness[]> {\n return manualChangeDetection(() => loader.getAllHarnesses(PopoverHarness.with(options)));\n }\n\n /** Predicate for `locatorFor` / `locatorForAll`. */\n static with(options: PopoverHarnessFilters = {}): HarnessPredicate<PopoverHarness> {\n return new HarnessPredicate(PopoverHarness, options).addOption(\n 'triggerText',\n options.triggerText,\n async (h, text) => HarnessPredicate.stringMatches(await h.getTriggerText(), text),\n );\n }\n\n /** The text currently rendered in the trigger, trimmed. */\n async getTriggerText(): Promise<string> {\n return manualChangeDetection(async () => {\n await afterSchedulerTick();\n return (await (await this.host()).text()).trim();\n });\n }\n\n /** Whether the popover is open, read from the trigger's `aria-expanded`. */\n async isOpen(): Promise<boolean> {\n return manualChangeDetection(async () => {\n await afterSchedulerTick();\n return (await (await this.host()).getAttribute('aria-expanded')) === 'true';\n });\n }\n\n /**\n * Opens the popover by clicking the trigger. No-op when already open.\n *\n * This is the gesture for the default `twPopoverTriggerOn=\"click\"`. A\n * `'focus'`- or `'manual'`-triggered popover is opened through the directive's\n * own `open()` (reachable via `exportAs: 'twPopover'`), not through a click.\n */\n async open(): Promise<void> {\n await manualChangeDetection(async () => {\n await afterSchedulerTick();\n const host = await this.host();\n if ((await host.getAttribute('aria-expanded')) === 'true') return;\n await host.click();\n await afterSchedulerTick();\n });\n }\n\n /**\n * Closes the popover by sending Escape to the trigger — the one dismissal\n * that works for click, focus and manual triggers alike. No-op when already\n * closed, and deliberately inert when `twPopoverCloseOnEscape` is `false`.\n *\n * Returns as soon as the key is dispatched. The panel detaches only after the\n * 120 ms leave window; poll the DOM for its removal before asserting.\n */\n async close(): Promise<void> {\n await manualChangeDetection(async () => {\n await afterSchedulerTick();\n const host = await this.host();\n if ((await host.getAttribute('aria-expanded')) !== 'true') return;\n await host.sendKeys(TestKey.ESCAPE);\n await afterSchedulerTick();\n });\n }\n\n /**\n * Text rendered inside the panel, trimmed, or `null` when the popover is\n * closed and the panel is detached.\n */\n async getText(): Promise<string | null> {\n return manualChangeDetection(async () => {\n await afterSchedulerTick();\n const panel = await this.getPanel();\n return panel ? (await panel.text()).trim() : null;\n });\n }\n\n /**\n * Whether the panel renders its directional arrow (`twPopoverArrow`). `false`\n * while the popover is closed, because the panel does not exist then.\n */\n async hasArrow(): Promise<boolean> {\n return manualChangeDetection(async () => {\n await afterSchedulerTick();\n const id = await this.getPanelId();\n if (!id) return false;\n // The arrow has no dedicated attribute hook: it is the panel wrapper's only\n // `aria-hidden` grandchild span, with the content nested one level deeper.\n const arrow = await this.documentRootLocatorFactory().locatorForOptional(\n `#${id} > div > span[aria-hidden=\"true\"]`,\n )();\n return arrow !== null;\n });\n }\n\n /**\n * The panel element, or `null` when the popover is closed. Callers are already\n * inside `manualChangeDetection`.\n */\n private async getPanel(): Promise<TestElement | null> {\n const id = await this.getPanelId();\n if (!id) return null;\n return this.documentRootLocatorFactory().locatorForOptional(`#${id}`)();\n }\n\n /**\n * The id of this trigger's own panel, or `null` when closed. Scoping by\n * `aria-controls` keeps sibling popovers apart.\n */\n private async getPanelId(): Promise<string | null> {\n return (await this.host()).getAttribute('aria-controls');\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cdevhub-ngx-tw-popover-testing.mjs","sources":["../../../projects/ngx-tw/popover/testing/popover-harness.ts","../../../projects/ngx-tw/popover/testing/cdevhub-ngx-tw-popover-testing.ts"],"sourcesContent":["import { ComponentHarness, HarnessPredicate, TestKey } from '@angular/cdk/testing';\nimport type { BaseHarnessFilters, TestElement } from '@angular/cdk/testing';\n\n/** Filters accepted by `PopoverHarness.with`. */\nexport interface PopoverHarnessFilters extends BaseHarnessFilters {\n /** Match by the text rendered in the trigger. */\n triggerText?: string | RegExp;\n}\n\n/**\n * Harness for a `[twPopover]` trigger and the panel it opens.\n *\n * ## Loading it\n *\n * The host is the trigger, which lives in the fixture, so the ordinary\n * `TestbedHarnessEnvironment.loader(fixture)` is correct. The panel renders into\n * the CDK overlay container outside the fixture, and this harness resolves it\n * internally via `documentRootLocatorFactory()` — a consumer never needs\n * `documentRootLoader`.\n *\n * The host selector is the directive's static `data-tw-popover-trigger` marker.\n * `[twPopover]` cannot be used: it takes a required `TemplateRef` or component\n * type, so it is always property-bound and Angular renders no attribute for a\n * bound input. The marker also makes the match exact, so no disambiguation\n * against `aria-haspopup=\"dialog\"` — which the two date-picker triggers also\n * carry — is needed.\n *\n * ## Waiting for the panel\n *\n * Every method stabilizes the fixture the way CDK harnesses always do, which\n * covers change detection but **not** the component's own timers: `popover.ts`\n * detaches the panel behind a hard-coded 120 ms leave window driven by a plain\n * `setTimeout`, which Angular's `PendingTasks` does not track, so\n * `whenStable()` does not wait for it. {@link close} therefore dispatches\n * Escape and returns while the panel is still attached. Poll the DOM for its\n * removal — `document.querySelector('tw-popover-overlay')` — and only then read\n * through the harness.\n *\n * ## The panel is detached, not disposed\n *\n * Unlike `tw-select`, closing a popover **detaches** the portal and keeps the\n * `OverlayRef` for reuse; it is only rebuilt when `twPopoverBackdrop` or\n * `twPopoverScrollStrategy` changes. The panel element is therefore absent while\n * closed and present again after a reopen, on the same overlay.\n */\nexport class PopoverHarness extends ComponentHarness {\n static hostSelector = '[data-tw-popover-trigger]';\n\n /** Predicate for `locatorFor` / `locatorForAll`. */\n static with(options: PopoverHarnessFilters = {}): HarnessPredicate<PopoverHarness> {\n return new HarnessPredicate(PopoverHarness, options).addOption(\n 'triggerText',\n options.triggerText,\n async (h, text) => HarnessPredicate.stringMatches(await h.getTriggerText(), text),\n );\n }\n\n /** The text currently rendered in the trigger, trimmed. */\n async getTriggerText(): Promise<string> {\n return (await (await this.host()).text()).trim();\n }\n\n /** Whether the popover is open, read from the trigger's `aria-expanded`. */\n async isOpen(): Promise<boolean> {\n return (await (await this.host()).getAttribute('aria-expanded')) === 'true';\n }\n\n /**\n * Opens the popover by clicking the trigger. No-op when already open.\n *\n * This is the gesture for the default `twPopoverTriggerOn=\"click\"`. A\n * `'focus'`- or `'manual'`-triggered popover is opened through the directive's\n * own `open()` (reachable via `exportAs: 'twPopover'`), not through a click.\n */\n async open(): Promise<void> {\n const host = await this.host();\n if ((await host.getAttribute('aria-expanded')) === 'true') return;\n await host.click();\n }\n\n /**\n * Closes the popover by sending Escape to the trigger — the one dismissal\n * that works for click, focus and manual triggers alike. No-op when already\n * closed, and deliberately inert when `twPopoverCloseOnEscape` is `false`.\n *\n * Returns as soon as the key is dispatched. The panel detaches only after the\n * 120 ms leave window; poll the DOM for its removal before asserting.\n */\n async close(): Promise<void> {\n const host = await this.host();\n if ((await host.getAttribute('aria-expanded')) !== 'true') return;\n await host.sendKeys(TestKey.ESCAPE);\n }\n\n /**\n * Text rendered inside the panel, trimmed, or `null` when the popover is\n * closed and the panel is detached.\n */\n async getText(): Promise<string | null> {\n const panel = await this.getPanel();\n return panel ? (await panel.text()).trim() : null;\n }\n\n /**\n * Whether the panel renders its directional arrow (`twPopoverArrow`). `false`\n * while the popover is closed, because the panel does not exist then.\n */\n async hasArrow(): Promise<boolean> {\n const id = await this.getPanelId();\n if (!id) return false;\n // The arrow has no dedicated attribute hook: it is the panel wrapper's only\n // `aria-hidden` grandchild span, with the content nested one level deeper.\n const arrow = await this.documentRootLocatorFactory().locatorForOptional(\n `#${id} > div > span[aria-hidden=\"true\"]`,\n )();\n return arrow !== null;\n }\n\n /** The panel element, or `null` when the popover is closed. */\n private async getPanel(): Promise<TestElement | null> {\n const id = await this.getPanelId();\n if (!id) return null;\n return this.documentRootLocatorFactory().locatorForOptional(`#${id}`)();\n }\n\n /**\n * The id of this trigger's own panel, or `null` when closed. Scoping by\n * `aria-controls` keeps sibling popovers apart.\n */\n private async getPanelId(): Promise<string | null> {\n return (await this.host()).getAttribute('aria-controls');\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":";;AASA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmCG;AACG,MAAO,cAAe,SAAQ,gBAAgB,CAAA;AAClD,IAAA,OAAO,YAAY,GAAG,2BAA2B;;AAGjD,IAAA,OAAO,IAAI,CAAC,OAAA,GAAiC,EAAE,EAAA;AAC7C,QAAA,OAAO,IAAI,gBAAgB,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC,SAAS,CAC5D,aAAa,EACb,OAAO,CAAC,WAAW,EACnB,OAAO,CAAC,EAAE,IAAI,KAAK,gBAAgB,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,cAAc,EAAE,EAAE,IAAI,CAAC,CAClF;IACH;;AAGA,IAAA,MAAM,cAAc,GAAA;AAClB,QAAA,OAAO,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE;IAClD;;AAGA,IAAA,MAAM,MAAM,GAAA;AACV,QAAA,OAAO,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,YAAY,CAAC,eAAe,CAAC,MAAM,MAAM;IAC7E;AAEA;;;;;;AAMG;AACH,IAAA,MAAM,IAAI,GAAA;AACR,QAAA,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE;QAC9B,IAAI,CAAC,MAAM,IAAI,CAAC,YAAY,CAAC,eAAe,CAAC,MAAM,MAAM;YAAE;AAC3D,QAAA,MAAM,IAAI,CAAC,KAAK,EAAE;IACpB;AAEA;;;;;;;AAOG;AACH,IAAA,MAAM,KAAK,GAAA;AACT,QAAA,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE;QAC9B,IAAI,CAAC,MAAM,IAAI,CAAC,YAAY,CAAC,eAAe,CAAC,MAAM,MAAM;YAAE;QAC3D,MAAM,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC;IACrC;AAEA;;;AAGG;AACH,IAAA,MAAM,OAAO,GAAA;AACX,QAAA,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,QAAQ,EAAE;AACnC,QAAA,OAAO,KAAK,GAAG,CAAC,MAAM,KAAK,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,GAAG,IAAI;IACnD;AAEA;;;AAGG;AACH,IAAA,MAAM,QAAQ,GAAA;AACZ,QAAA,MAAM,EAAE,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE;AAClC,QAAA,IAAI,CAAC,EAAE;AAAE,YAAA,OAAO,KAAK;;;AAGrB,QAAA,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,0BAA0B,EAAE,CAAC,kBAAkB,CACtE,CAAA,CAAA,EAAI,EAAE,CAAA,iCAAA,CAAmC,CAC1C,EAAE;QACH,OAAO,KAAK,KAAK,IAAI;IACvB;;AAGQ,IAAA,MAAM,QAAQ,GAAA;AACpB,QAAA,MAAM,EAAE,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE;AAClC,QAAA,IAAI,CAAC,EAAE;AAAE,YAAA,OAAO,IAAI;AACpB,QAAA,OAAO,IAAI,CAAC,0BAA0B,EAAE,CAAC,kBAAkB,CAAC,CAAA,CAAA,EAAI,EAAE,CAAA,CAAE,CAAC,EAAE;IACzE;AAEA;;;AAGG;AACK,IAAA,MAAM,UAAU,GAAA;AACtB,QAAA,OAAO,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,YAAY,CAAC,eAAe,CAAC;IAC1D;;;ACnIF;;AAEG;;;;"}
@@ -1,24 +1,5 @@
1
- import { ComponentHarness, manualChangeDetection, HarnessPredicate } from '@angular/cdk/testing';
1
+ import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';
2
2
 
3
- /**
4
- * Lets the zoneless change-detection scheduler run its pending tick.
5
- *
6
- * `ApplicationRef` schedules a tick with `setTimeout(cb)` raced against
7
- * `requestAnimationFrame`. A timer registered *after* the notify that dirtied a
8
- * signal therefore fires *after* that tick, so one macrotask is enough to
9
- * observe everything already scheduled. It is a fixed, bounded yield, not a
10
- * stabilization await, so it cannot hang.
11
- *
12
- * Every method spends one: an action yields after dispatching, so the effects
13
- * the directive applied synchronously are rendered; a read yields before
14
- * looking, so it sees any tick that was already pending. The composition is what
15
- * matters — `show()` returns while the panel is still behind
16
- * `twTooltipShowDelay`, and the following read's own yield is what picks the
17
- * rendered content up.
18
- */
19
- function afterSchedulerTick() {
20
- return new Promise((resolve) => setTimeout(resolve));
21
- }
22
3
  /**
23
4
  * Harness for a `[twTooltip]` trigger and the panel it shows.
24
5
  *
@@ -27,44 +8,6 @@ function afterSchedulerTick() {
27
8
  * configuration, not state, and a harness method for any of them would freeze an
28
9
  * API that may still move — so none is offered.
29
10
  *
30
- * ## Nothing here awaits application stabilization, and that is load-bearing
31
- *
32
- * `TestbedHarnessEnvironment` routes every `TestElement` operation through
33
- * `forceStabilize()` — `fixture.detectChanges()` then
34
- * `await fixture.whenStable()` — and that await resolves only when Angular's
35
- * `PendingTasks` set is empty. Under full-suite contention it was observed
36
- * **not to resolve at all**, and everything built on it hung for the whole test
37
- * budget instead of failing. This harness was withdrawn once for that, on five
38
- * green local runs followed by one red CI run.
39
- *
40
- * Every method body therefore runs inside CDK's `manualChangeDetection()`,
41
- * which sets the flag `forceStabilize()` early-returns on, and so does
42
- * acquisition, via {@link load} / {@link loadAll}. The spec beside this file
43
- * adds the third piece: it never awaits `fixture.whenStable()` either, not even
44
- * in `beforeEach`. All three were needed — each of the two CI failures during
45
- * this restoration was traced to one of them, and the second landed on
46
- * `popover` rather than here, which is how it became clear the fault belongs to
47
- * whichever harness spec lands in the unlucky worker slot rather than to any
48
- * one component. `grep -c whenStable` over this file and its spec returns zero,
49
- * which is the whole claim and is checkable in one command rather than by
50
- * counting green runs. The spec pins the rest with tests that hold a real
51
- * `PendingTasks` entry open across acquisition and every method.
52
- *
53
- * Why the application stops stabilizing is **not** known; this removes the
54
- * dependency rather than curing it.
55
- *
56
- * The cost is that change detection is not forced on your behalf. Instead every
57
- * method spends one macrotask on the scheduler (see {@link afterSchedulerTick}),
58
- * which covers everything already scheduled — including the panel's first
59
- * render, which is why {@link getTooltipText} does not come back empty on a
60
- * tooltip that has only just attached. What it does not cover is state behind
61
- * the component's own timers: {@link show} and {@link hide} dispatch the
62
- * interaction and return, and the panel appears or detaches only once
63
- * `twTooltipShowDelay` (200 ms by default) or `twTooltipHideDelay` (150 ms)
64
- * elapses. Set both to `0` in a fixture, poll the DOM for the panel —
65
- * `document.querySelector` needs no stabilization and so can neither hang nor
66
- * burn a fixed interval — and only then read through the harness.
67
- *
68
11
  * ## Loading it
69
12
  *
70
13
  * The host is the trigger, which lives in the fixture, so the ordinary
@@ -85,80 +28,51 @@ function afterSchedulerTick() {
85
28
  * panel is resolved as "the tooltip showing in the document". That is exact for
86
29
  * the hover/focus model, where only one tooltip is visible at a time, but a test
87
30
  * that forces two open at once cannot tell them apart.
31
+ *
32
+ * ## Waiting for the panel
33
+ *
34
+ * Every method stabilizes the fixture the way CDK harnesses always do, which
35
+ * covers change detection but **not** the component's own timers: show and hide
36
+ * are driven by plain `setTimeout`s behind `twTooltipShowDelay` (200 ms by
37
+ * default) and `twTooltipHideDelay` (150 ms), which Angular's `PendingTasks`
38
+ * does not track, so `whenStable()` does not wait for them — not even at a delay
39
+ * of `0`. {@link show} and {@link hide} therefore dispatch the interaction and
40
+ * return before anything has attached or detached. Set both delays to `0` in the
41
+ * fixture, poll the DOM for the panel —
42
+ * `document.querySelector('tw-tooltip-overlay')` — and only then read through
43
+ * the harness.
88
44
  */
89
45
  class TooltipHarness extends ComponentHarness {
90
46
  static hostSelector = '[data-tw-tooltip-trigger]';
91
47
  /** Resolves the tooltip panel, which lives outside this harness's host. */
92
48
  panel = this.documentRootLocatorFactory().locatorForOptional('tw-tooltip-overlay');
93
- /**
94
- * Acquires one harness without waiting for the application to stabilize —
95
- * the counterpart to the guarantee the methods below make.
96
- *
97
- * `loader.getHarness(...)` is CDK's own acquisition path and it stabilizes:
98
- * `getAllRawElements` calls `forceStabilize()`, and `HarnessPredicate`
99
- * filtering routes through `parallel()`, which asks *every* active fixture in
100
- * the worker to settle. Both await `fixture.whenStable()`, which is the one
101
- * thing this harness exists to avoid — and the failure that withdrew it was
102
- * observed there, at acquisition, before any method had run.
103
- *
104
- * So acquisition is wrapped too, and `manualChangeDetection()` nests: the
105
- * inner `parallel()` sees the flag already set and skips the stabilization
106
- * entirely. **Render the fixture first** (`fixture.detectChanges()`), because
107
- * nothing here will do it for you; an unrendered fixture fails loudly with
108
- * CDK's "failed to find element" rather than returning something wrong.
109
- *
110
- * Plain `loader.getHarness(TooltipHarness)` still works and is still supported.
111
- * This is the path to use when a suite must not be able to hang.
112
- */
113
- static load(loader, options = {}) {
114
- return manualChangeDetection(() => loader.getHarness(TooltipHarness.with(options)));
115
- }
116
- /** {@link load} for every matching trigger rather than the first. */
117
- static loadAll(loader, options = {}) {
118
- return manualChangeDetection(() => loader.getAllHarnesses(TooltipHarness.with(options)));
119
- }
120
49
  /** Predicate for `locatorFor` / `locatorForAll`. */
121
50
  static with(options = {}) {
122
51
  return new HarnessPredicate(TooltipHarness, options).addOption('triggerText', options.triggerText, async (h, text) => HarnessPredicate.stringMatches(await h.getTriggerText(), text));
123
52
  }
124
53
  /** The text currently rendered in the trigger, trimmed. */
125
54
  async getTriggerText() {
126
- return manualChangeDetection(async () => {
127
- await afterSchedulerTick();
128
- return (await (await this.host()).text()).trim();
129
- });
55
+ return (await (await this.host()).text()).trim();
130
56
  }
131
57
  /** Whether a tooltip panel is currently showing. */
132
58
  async isOpen() {
133
- return manualChangeDetection(async () => {
134
- await afterSchedulerTick();
135
- return (await this.panel()) !== null;
136
- });
59
+ return (await this.panel()) !== null;
137
60
  }
138
61
  /**
139
62
  * The tooltip's message, trimmed, or `null` when nothing is showing. Works
140
63
  * for string and `TemplateRef` content alike.
141
64
  */
142
65
  async getTooltipText() {
143
- return manualChangeDetection(async () => {
144
- await afterSchedulerTick();
145
- const panel = await this.panel();
146
- return panel ? (await panel.text()).trim() : null;
147
- });
66
+ const panel = await this.panel();
67
+ return panel ? (await panel.text()).trim() : null;
148
68
  }
149
69
  /** Hovers the trigger. The panel appears once `twTooltipShowDelay` elapses. */
150
70
  async show() {
151
- await manualChangeDetection(async () => {
152
- await (await this.host()).hover();
153
- await afterSchedulerTick();
154
- });
71
+ await (await this.host()).hover();
155
72
  }
156
73
  /** Moves the pointer off the trigger. The panel detaches once `twTooltipHideDelay` elapses. */
157
74
  async hide() {
158
- await manualChangeDetection(async () => {
159
- await (await this.host()).mouseAway();
160
- await afterSchedulerTick();
161
- });
75
+ await (await this.host()).mouseAway();
162
76
  }
163
77
  /**
164
78
  * Focuses the trigger — the keyboard equivalent of {@link show}, and the path
@@ -169,21 +83,15 @@ class TooltipHarness extends ComponentHarness {
169
83
  * treats a repeated show as a no-op, so the belt-and-braces pair is safe.
170
84
  */
171
85
  async focusTrigger() {
172
- await manualChangeDetection(async () => {
173
- const host = await this.host();
174
- await host.focus();
175
- await host.dispatchEvent('focusin');
176
- await afterSchedulerTick();
177
- });
86
+ const host = await this.host();
87
+ await host.focus();
88
+ await host.dispatchEvent('focusin');
178
89
  }
179
90
  /** Blurs the trigger — the keyboard equivalent of {@link hide}. */
180
91
  async blurTrigger() {
181
- await manualChangeDetection(async () => {
182
- const host = await this.host();
183
- await host.blur();
184
- await host.dispatchEvent('focusout');
185
- await afterSchedulerTick();
186
- });
92
+ const host = await this.host();
93
+ await host.blur();
94
+ await host.dispatchEvent('focusout');
187
95
  }
188
96
  }
189
97
 
@@ -1 +1 @@
1
- {"version":3,"file":"cdevhub-ngx-tw-tooltip-testing.mjs","sources":["../../../projects/ngx-tw/tooltip/testing/tooltip-harness.ts","../../../projects/ngx-tw/tooltip/testing/cdevhub-ngx-tw-tooltip-testing.ts"],"sourcesContent":["import { ComponentHarness, HarnessPredicate, manualChangeDetection } from '@angular/cdk/testing';\nimport type { BaseHarnessFilters, HarnessLoader, TestElement } from '@angular/cdk/testing';\n\n/** Filters accepted by `TooltipHarness.with`. */\nexport interface TooltipHarnessFilters extends BaseHarnessFilters {\n /** Match by the text rendered in the trigger. */\n triggerText?: string | RegExp;\n}\n\n/**\n * Lets the zoneless change-detection scheduler run its pending tick.\n *\n * `ApplicationRef` schedules a tick with `setTimeout(cb)` raced against\n * `requestAnimationFrame`. A timer registered *after* the notify that dirtied a\n * signal therefore fires *after* that tick, so one macrotask is enough to\n * observe everything already scheduled. It is a fixed, bounded yield, not a\n * stabilization await, so it cannot hang.\n *\n * Every method spends one: an action yields after dispatching, so the effects\n * the directive applied synchronously are rendered; a read yields before\n * looking, so it sees any tick that was already pending. The composition is what\n * matters — `show()` returns while the panel is still behind\n * `twTooltipShowDelay`, and the following read's own yield is what picks the\n * rendered content up.\n */\nfunction afterSchedulerTick(): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve));\n}\n\n/**\n * Harness for a `[twTooltip]` trigger and the panel it shows.\n *\n * Deliberately narrow: a tooltip's whole observable surface is *whether it is\n * showing and what it says*. Position, delays, color, size and arrow are\n * configuration, not state, and a harness method for any of them would freeze an\n * API that may still move — so none is offered.\n *\n * ## Nothing here awaits application stabilization, and that is load-bearing\n *\n * `TestbedHarnessEnvironment` routes every `TestElement` operation through\n * `forceStabilize()` — `fixture.detectChanges()` then\n * `await fixture.whenStable()` — and that await resolves only when Angular's\n * `PendingTasks` set is empty. Under full-suite contention it was observed\n * **not to resolve at all**, and everything built on it hung for the whole test\n * budget instead of failing. This harness was withdrawn once for that, on five\n * green local runs followed by one red CI run.\n *\n * Every method body therefore runs inside CDK's `manualChangeDetection()`,\n * which sets the flag `forceStabilize()` early-returns on, and so does\n * acquisition, via {@link load} / {@link loadAll}. The spec beside this file\n * adds the third piece: it never awaits `fixture.whenStable()` either, not even\n * in `beforeEach`. All three were needed — each of the two CI failures during\n * this restoration was traced to one of them, and the second landed on\n * `popover` rather than here, which is how it became clear the fault belongs to\n * whichever harness spec lands in the unlucky worker slot rather than to any\n * one component. `grep -c whenStable` over this file and its spec returns zero,\n * which is the whole claim and is checkable in one command rather than by\n * counting green runs. The spec pins the rest with tests that hold a real\n * `PendingTasks` entry open across acquisition and every method.\n *\n * Why the application stops stabilizing is **not** known; this removes the\n * dependency rather than curing it.\n *\n * The cost is that change detection is not forced on your behalf. Instead every\n * method spends one macrotask on the scheduler (see {@link afterSchedulerTick}),\n * which covers everything already scheduled — including the panel's first\n * render, which is why {@link getTooltipText} does not come back empty on a\n * tooltip that has only just attached. What it does not cover is state behind\n * the component's own timers: {@link show} and {@link hide} dispatch the\n * interaction and return, and the panel appears or detaches only once\n * `twTooltipShowDelay` (200 ms by default) or `twTooltipHideDelay` (150 ms)\n * elapses. Set both to `0` in a fixture, poll the DOM for the panel —\n * `document.querySelector` needs no stabilization and so can neither hang nor\n * burn a fixed interval — and only then read through the harness.\n *\n * ## Loading it\n *\n * The host is the trigger, which lives in the fixture, so the ordinary\n * `TestbedHarnessEnvironment.loader(fixture)` is correct. The panel renders into\n * the CDK overlay container outside the fixture, and this harness resolves it\n * internally via `documentRootLocatorFactory()` — a consumer never needs\n * `documentRootLoader`.\n *\n * The host selector is the directive's static `data-tw-tooltip-trigger` marker,\n * which matches both spellings of the input: `twTooltip=\"literal\"` and the bound\n * `[twTooltip]=\"expr()\"`, for which Angular renders no attribute at all. A\n * harness matching the directive's own selector would silently miss every bound\n * trigger.\n *\n * Unlike `MenuHarness` and `PopoverHarness`, a tooltip trigger carries no\n * `aria-controls` linking it to its panel (`aria-describedby` points at CDK\n * `AriaDescriber`'s shared hidden message element for string content), so the\n * panel is resolved as \"the tooltip showing in the document\". That is exact for\n * the hover/focus model, where only one tooltip is visible at a time, but a test\n * that forces two open at once cannot tell them apart.\n */\nexport class TooltipHarness extends ComponentHarness {\n static hostSelector = '[data-tw-tooltip-trigger]';\n\n /** Resolves the tooltip panel, which lives outside this harness's host. */\n private readonly panel =\n this.documentRootLocatorFactory().locatorForOptional('tw-tooltip-overlay');\n\n /**\n * Acquires one harness without waiting for the application to stabilize —\n * the counterpart to the guarantee the methods below make.\n *\n * `loader.getHarness(...)` is CDK's own acquisition path and it stabilizes:\n * `getAllRawElements` calls `forceStabilize()`, and `HarnessPredicate`\n * filtering routes through `parallel()`, which asks *every* active fixture in\n * the worker to settle. Both await `fixture.whenStable()`, which is the one\n * thing this harness exists to avoid — and the failure that withdrew it was\n * observed there, at acquisition, before any method had run.\n *\n * So acquisition is wrapped too, and `manualChangeDetection()` nests: the\n * inner `parallel()` sees the flag already set and skips the stabilization\n * entirely. **Render the fixture first** (`fixture.detectChanges()`), because\n * nothing here will do it for you; an unrendered fixture fails loudly with\n * CDK's \"failed to find element\" rather than returning something wrong.\n *\n * Plain `loader.getHarness(TooltipHarness)` still works and is still supported.\n * This is the path to use when a suite must not be able to hang.\n */\n static load(loader: HarnessLoader, options: TooltipHarnessFilters = {}): Promise<TooltipHarness> {\n return manualChangeDetection(() => loader.getHarness(TooltipHarness.with(options)));\n }\n\n /** {@link load} for every matching trigger rather than the first. */\n static loadAll(\n loader: HarnessLoader,\n options: TooltipHarnessFilters = {},\n ): Promise<TooltipHarness[]> {\n return manualChangeDetection(() => loader.getAllHarnesses(TooltipHarness.with(options)));\n }\n\n /** Predicate for `locatorFor` / `locatorForAll`. */\n static with(options: TooltipHarnessFilters = {}): HarnessPredicate<TooltipHarness> {\n return new HarnessPredicate(TooltipHarness, options).addOption(\n 'triggerText',\n options.triggerText,\n async (h, text) => HarnessPredicate.stringMatches(await h.getTriggerText(), text),\n );\n }\n\n /** The text currently rendered in the trigger, trimmed. */\n async getTriggerText(): Promise<string> {\n return manualChangeDetection(async () => {\n await afterSchedulerTick();\n return (await (await this.host()).text()).trim();\n });\n }\n\n /** Whether a tooltip panel is currently showing. */\n async isOpen(): Promise<boolean> {\n return manualChangeDetection(async () => {\n await afterSchedulerTick();\n return (await this.panel()) !== null;\n });\n }\n\n /**\n * The tooltip's message, trimmed, or `null` when nothing is showing. Works\n * for string and `TemplateRef` content alike.\n */\n async getTooltipText(): Promise<string | null> {\n return manualChangeDetection(async () => {\n await afterSchedulerTick();\n const panel: TestElement | null = await this.panel();\n return panel ? (await panel.text()).trim() : null;\n });\n }\n\n /** Hovers the trigger. The panel appears once `twTooltipShowDelay` elapses. */\n async show(): Promise<void> {\n await manualChangeDetection(async () => {\n await (await this.host()).hover();\n await afterSchedulerTick();\n });\n }\n\n /** Moves the pointer off the trigger. The panel detaches once `twTooltipHideDelay` elapses. */\n async hide(): Promise<void> {\n await manualChangeDetection(async () => {\n await (await this.host()).mouseAway();\n await afterSchedulerTick();\n });\n }\n\n /**\n * Focuses the trigger — the keyboard equivalent of {@link show}, and the path\n * WCAG 2.1 SC 1.4.13 requires to work.\n *\n * Moves real DOM focus *and* dispatches `focusin`, because a programmatic\n * `focus()` does not reliably raise `focusin` in every test DOM. The directive\n * treats a repeated show as a no-op, so the belt-and-braces pair is safe.\n */\n async focusTrigger(): Promise<void> {\n await manualChangeDetection(async () => {\n const host = await this.host();\n await host.focus();\n await host.dispatchEvent('focusin');\n await afterSchedulerTick();\n });\n }\n\n /** Blurs the trigger — the keyboard equivalent of {@link hide}. */\n async blurTrigger(): Promise<void> {\n await manualChangeDetection(async () => {\n const host = await this.host();\n await host.blur();\n await host.dispatchEvent('focusout');\n await afterSchedulerTick();\n });\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cdevhub-ngx-tw-tooltip-testing.mjs","sources":["../../../projects/ngx-tw/tooltip/testing/tooltip-harness.ts","../../../projects/ngx-tw/tooltip/testing/cdevhub-ngx-tw-tooltip-testing.ts"],"sourcesContent":["import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';\nimport type { BaseHarnessFilters, TestElement } from '@angular/cdk/testing';\n\n/** Filters accepted by `TooltipHarness.with`. */\nexport interface TooltipHarnessFilters extends BaseHarnessFilters {\n /** Match by the text rendered in the trigger. */\n triggerText?: string | RegExp;\n}\n\n/**\n * Harness for a `[twTooltip]` trigger and the panel it shows.\n *\n * Deliberately narrow: a tooltip's whole observable surface is *whether it is\n * showing and what it says*. Position, delays, color, size and arrow are\n * configuration, not state, and a harness method for any of them would freeze an\n * API that may still move — so none is offered.\n *\n * ## Loading it\n *\n * The host is the trigger, which lives in the fixture, so the ordinary\n * `TestbedHarnessEnvironment.loader(fixture)` is correct. The panel renders into\n * the CDK overlay container outside the fixture, and this harness resolves it\n * internally via `documentRootLocatorFactory()` — a consumer never needs\n * `documentRootLoader`.\n *\n * The host selector is the directive's static `data-tw-tooltip-trigger` marker,\n * which matches both spellings of the input: `twTooltip=\"literal\"` and the bound\n * `[twTooltip]=\"expr()\"`, for which Angular renders no attribute at all. A\n * harness matching the directive's own selector would silently miss every bound\n * trigger.\n *\n * Unlike `MenuHarness` and `PopoverHarness`, a tooltip trigger carries no\n * `aria-controls` linking it to its panel (`aria-describedby` points at CDK\n * `AriaDescriber`'s shared hidden message element for string content), so the\n * panel is resolved as \"the tooltip showing in the document\". That is exact for\n * the hover/focus model, where only one tooltip is visible at a time, but a test\n * that forces two open at once cannot tell them apart.\n *\n * ## Waiting for the panel\n *\n * Every method stabilizes the fixture the way CDK harnesses always do, which\n * covers change detection but **not** the component's own timers: show and hide\n * are driven by plain `setTimeout`s behind `twTooltipShowDelay` (200 ms by\n * default) and `twTooltipHideDelay` (150 ms), which Angular's `PendingTasks`\n * does not track, so `whenStable()` does not wait for them — not even at a delay\n * of `0`. {@link show} and {@link hide} therefore dispatch the interaction and\n * return before anything has attached or detached. Set both delays to `0` in the\n * fixture, poll the DOM for the panel —\n * `document.querySelector('tw-tooltip-overlay')` — and only then read through\n * the harness.\n */\nexport class TooltipHarness extends ComponentHarness {\n static hostSelector = '[data-tw-tooltip-trigger]';\n\n /** Resolves the tooltip panel, which lives outside this harness's host. */\n private readonly panel =\n this.documentRootLocatorFactory().locatorForOptional('tw-tooltip-overlay');\n\n /** Predicate for `locatorFor` / `locatorForAll`. */\n static with(options: TooltipHarnessFilters = {}): HarnessPredicate<TooltipHarness> {\n return new HarnessPredicate(TooltipHarness, options).addOption(\n 'triggerText',\n options.triggerText,\n async (h, text) => HarnessPredicate.stringMatches(await h.getTriggerText(), text),\n );\n }\n\n /** The text currently rendered in the trigger, trimmed. */\n async getTriggerText(): Promise<string> {\n return (await (await this.host()).text()).trim();\n }\n\n /** Whether a tooltip panel is currently showing. */\n async isOpen(): Promise<boolean> {\n return (await this.panel()) !== null;\n }\n\n /**\n * The tooltip's message, trimmed, or `null` when nothing is showing. Works\n * for string and `TemplateRef` content alike.\n */\n async getTooltipText(): Promise<string | null> {\n const panel: TestElement | null = await this.panel();\n return panel ? (await panel.text()).trim() : null;\n }\n\n /** Hovers the trigger. The panel appears once `twTooltipShowDelay` elapses. */\n async show(): Promise<void> {\n await (await this.host()).hover();\n }\n\n /** Moves the pointer off the trigger. The panel detaches once `twTooltipHideDelay` elapses. */\n async hide(): Promise<void> {\n await (await this.host()).mouseAway();\n }\n\n /**\n * Focuses the trigger — the keyboard equivalent of {@link show}, and the path\n * WCAG 2.1 SC 1.4.13 requires to work.\n *\n * Moves real DOM focus *and* dispatches `focusin`, because a programmatic\n * `focus()` does not reliably raise `focusin` in every test DOM. The directive\n * treats a repeated show as a no-op, so the belt-and-braces pair is safe.\n */\n async focusTrigger(): Promise<void> {\n const host = await this.host();\n await host.focus();\n await host.dispatchEvent('focusin');\n }\n\n /** Blurs the trigger — the keyboard equivalent of {@link hide}. */\n async blurTrigger(): Promise<void> {\n const host = await this.host();\n await host.blur();\n await host.dispatchEvent('focusout');\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":";;AASA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCG;AACG,MAAO,cAAe,SAAQ,gBAAgB,CAAA;AAClD,IAAA,OAAO,YAAY,GAAG,2BAA2B;;IAGhC,KAAK,GACpB,IAAI,CAAC,0BAA0B,EAAE,CAAC,kBAAkB,CAAC,oBAAoB,CAAC;;AAG5E,IAAA,OAAO,IAAI,CAAC,OAAA,GAAiC,EAAE,EAAA;AAC7C,QAAA,OAAO,IAAI,gBAAgB,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC,SAAS,CAC5D,aAAa,EACb,OAAO,CAAC,WAAW,EACnB,OAAO,CAAC,EAAE,IAAI,KAAK,gBAAgB,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,cAAc,EAAE,EAAE,IAAI,CAAC,CAClF;IACH;;AAGA,IAAA,MAAM,cAAc,GAAA;AAClB,QAAA,OAAO,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE;IAClD;;AAGA,IAAA,MAAM,MAAM,GAAA;QACV,OAAO,CAAC,MAAM,IAAI,CAAC,KAAK,EAAE,MAAM,IAAI;IACtC;AAEA;;;AAGG;AACH,IAAA,MAAM,cAAc,GAAA;AAClB,QAAA,MAAM,KAAK,GAAuB,MAAM,IAAI,CAAC,KAAK,EAAE;AACpD,QAAA,OAAO,KAAK,GAAG,CAAC,MAAM,KAAK,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,GAAG,IAAI;IACnD;;AAGA,IAAA,MAAM,IAAI,GAAA;QACR,MAAM,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE;IACnC;;AAGA,IAAA,MAAM,IAAI,GAAA;QACR,MAAM,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE;IACvC;AAEA;;;;;;;AAOG;AACH,IAAA,MAAM,YAAY,GAAA;AAChB,QAAA,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE;AAC9B,QAAA,MAAM,IAAI,CAAC,KAAK,EAAE;AAClB,QAAA,MAAM,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC;IACrC;;AAGA,IAAA,MAAM,WAAW,GAAA;AACf,QAAA,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE;AAC9B,QAAA,MAAM,IAAI,CAAC,IAAI,EAAE;AACjB,QAAA,MAAM,IAAI,CAAC,aAAa,CAAC,UAAU,CAAC;IACtC;;;ACnHF;;AAEG;;;;"}