diff --git a/docs/configuration/templates.md b/docs/configuration/templates.md index a7df11e5..36d24004 100644 --- a/docs/configuration/templates.md +++ b/docs/configuration/templates.md @@ -20,6 +20,12 @@ The Advanced Camera Card uses Consult its documentation for the wide variety of different template values supported. +> [!NOTE] +> To keep the card lightweight, some template features are not available. If a +> template feature you need is missing, +> [open an issue](https://github.com/dermotduffy/advanced-camera-card/issues) to +> request it. + See [an example](../examples.md?id=accessing-home-assistant-state) that accesses Home Assistant state. diff --git a/package.json b/package.json index 35d8ba3b..c99f1b8b 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "date-fns": "^3.6.0", "embla-carousel": "^8.6.0", "embla-carousel-wheel-gestures": "^8.0.1", - "ha-nunjucks": "^1.3.0", + "ha-nunjucks": "~1.6.2", "home-assistant-js-websocket": "^9.4.0", "js-yaml": "^4.1.0", "keycharm": "^0.4.0", diff --git a/rollup.config.js b/rollup.config.js index c75712bc..d46e3ace 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -35,7 +35,16 @@ const serveopts = { * @type {import('rollup').RollupOptions['plugins']} */ const plugins = [ - gitInfo.default({ enableBuildDate: true, updateVersion: false }), + gitInfo.default( + // Limit git-info to the project's own package.json. Without this it also + // rewrites any dependency's imported package.json into ESM, which then breaks + // the json() plugin downstream (ha-nunjucks imports its own package.json). + { + enableBuildDate: true, + updateVersion: false, + include: 'package.json', + }, + ), styles({ modules: false, // Behavior of inject mode, without actually injecting style diff --git a/tests/card-controller/folders/ha/media-matcher.test.ts b/tests/card-controller/folders/ha/media-matcher.test.ts index 2014a51e..8d814475 100644 --- a/tests/card-controller/folders/ha/media-matcher.test.ts +++ b/tests/card-controller/folders/ha/media-matcher.test.ts @@ -8,8 +8,15 @@ import type { BrowseMediaMetadata, RichBrowseMedia, } from '../../../../src/ha/browse-media/types'; -import { createHASS, createMockTemplateRenderer } from '../../../test-utils'; +import { + createHASS, + createMockTemplateRenderer, + stubConnectedHomeAssistant, +} from '../../../test-utils'; +// The `with template matcher` test loads the real ha-nunjucks engine, which +// reads `window`/`document` at import, so this suite needs a DOM environment. +// @vitest-environment jsdom describe('MediaMatcher', () => { // A mock renderer for the matcher plumbing, which never renders templates. // The `value_template` suite below uses its own real, loaded engine. @@ -153,6 +160,7 @@ describe('MediaMatcher', () => { // renderer (rather than the shared mock). const templateManager = new TemplateManager(); beforeAll(async () => { + stubConnectedHomeAssistant(); await templateManager.loadRenderer(); }); diff --git a/tests/card-controller/templates/index.test.ts b/tests/card-controller/templates/index.test.ts index a4759d69..882904c0 100644 --- a/tests/card-controller/templates/index.test.ts +++ b/tests/card-controller/templates/index.test.ts @@ -1,9 +1,19 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import { TemplateManager } from '../../../src/card-controller/templates/index'; -import { createConfig, createHASS, createStateEntity } from '../../test-utils'; +import { + createConfig, + createHASS, + createStateEntity, + stubConnectedHomeAssistant, +} from '../../test-utils'; +// ha-nunjucks reads `window`/`document` at import and renders via a +// `window.haNunjucks` global, so the renderer needs a DOM environment. +// @vitest-environment jsdom describe('TemplateManager', () => { + beforeAll(() => stubConnectedHomeAssistant()); + describe('loadRenderer', () => { it('should render a template raw before the engine is loaded', () => { const manager = new TemplateManager(); diff --git a/tests/condition-trigger/common/time-period.test.ts b/tests/condition-trigger/common/time-period.test.ts index 23f21132..b7f564f0 100644 --- a/tests/condition-trigger/common/time-period.test.ts +++ b/tests/condition-trigger/common/time-period.test.ts @@ -6,6 +6,7 @@ import { createHASS, createMockTemplateRenderer, createStateEntity, + stubConnectedHomeAssistant, } from '../../test-utils'; // @vitest-environment jsdom @@ -66,6 +67,7 @@ describe('renderTimePeriodToSeconds', () => { const templateManager = new TemplateManager(); beforeAll(async () => { + stubConnectedHomeAssistant(); await templateManager.loadRenderer(); }); diff --git a/tests/condition-trigger/conditions/conditions-manager.test.ts b/tests/condition-trigger/conditions/conditions-manager.test.ts index 063d30b3..75024a94 100644 --- a/tests/condition-trigger/conditions/conditions-manager.test.ts +++ b/tests/condition-trigger/conditions/conditions-manager.test.ts @@ -7,6 +7,7 @@ import { createHASS, createMockTemplateRenderer, createStateEntity, + stubConnectedHomeAssistant, } from '../../test-utils'; // A mock renderer for the orchestration tests, which never render templates. @@ -198,6 +199,7 @@ describe('ConditionsManager', () => { // loaded for the synchronous renderer (rather than the shared mock). const templateManager = new TemplateManager(); beforeAll(async () => { + stubConnectedHomeAssistant(); await templateManager.loadRenderer(); }); diff --git a/tests/condition-trigger/conditions/conditions/numeric-state.test.ts b/tests/condition-trigger/conditions/conditions/numeric-state.test.ts index e2da0b53..77fcc827 100644 --- a/tests/condition-trigger/conditions/conditions/numeric-state.test.ts +++ b/tests/condition-trigger/conditions/conditions/numeric-state.test.ts @@ -6,6 +6,7 @@ import { createHASS, createMockTemplateRenderer, createStateEntity, + stubConnectedHomeAssistant, } from '../../../test-utils'; import { createEvaluatorContext } from './test-utils'; @@ -131,6 +132,7 @@ describe('numeric state condition', () => { // This case renders a real value_template, so load the lazily-imported // engine for the synchronous renderer. const templateManager = new TemplateManager(); + stubConnectedHomeAssistant(); await templateManager.loadRenderer(); const evaluator = createConditionEvaluator( diff --git a/tests/condition-trigger/conditions/conditions/state.test.ts b/tests/condition-trigger/conditions/conditions/state.test.ts index 37e7e9fa..6c7dce22 100644 --- a/tests/condition-trigger/conditions/conditions/state.test.ts +++ b/tests/condition-trigger/conditions/conditions/state.test.ts @@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TemplateManager } from '../../../../src/card-controller/templates'; import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; -import { createHASS, createStateEntity } from '../../../test-utils'; +import { + createHASS, + createStateEntity, + stubConnectedHomeAssistant, +} from '../../../test-utils'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom @@ -340,6 +344,7 @@ describe('state condition', () => { // This case renders a real templated `for`, so load the lazily-imported // engine for the synchronous renderer. const templateManager = new TemplateManager(); + stubConnectedHomeAssistant(); await templateManager.loadRenderer(); const evaluator = createConditionEvaluator( diff --git a/tests/condition-trigger/conditions/conditions/template.test.ts b/tests/condition-trigger/conditions/conditions/template.test.ts index 26e4511a..ca05cb11 100644 --- a/tests/condition-trigger/conditions/conditions/template.test.ts +++ b/tests/condition-trigger/conditions/conditions/template.test.ts @@ -2,13 +2,18 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { TemplateManager } from '../../../../src/card-controller/templates'; import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; -import { createHASS, createStateEntity } from '../../../test-utils'; +import { + createHASS, + createStateEntity, + stubConnectedHomeAssistant, +} from '../../../test-utils'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom describe('template condition', () => { const templateManager = new TemplateManager(); beforeAll(async () => { + stubConnectedHomeAssistant(); await templateManager.loadRenderer(); }); diff --git a/tests/condition-trigger/triggers/manager.test.ts b/tests/condition-trigger/triggers/manager.test.ts index 12ab581a..1f0ac9f8 100644 --- a/tests/condition-trigger/triggers/manager.test.ts +++ b/tests/condition-trigger/triggers/manager.test.ts @@ -9,6 +9,7 @@ import { createHASSManager, createMockTemplateRenderer, createStateEntity, + stubConnectedHomeAssistant, } from '../../test-utils'; const templateManager = createMockTemplateRenderer(); @@ -126,6 +127,7 @@ describe('TriggersManager', () => { // loaded for the synchronous renderer (rather than the shared mock). const templateManager = new TemplateManager(); beforeAll(async () => { + stubConnectedHomeAssistant(); await templateManager.loadRenderer(); }); diff --git a/tests/condition-trigger/triggers/triggers/numeric-state.test.ts b/tests/condition-trigger/triggers/triggers/numeric-state.test.ts index a48fffa8..16a3e02d 100644 --- a/tests/condition-trigger/triggers/triggers/numeric-state.test.ts +++ b/tests/condition-trigger/triggers/triggers/numeric-state.test.ts @@ -8,7 +8,11 @@ import { import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { NumericStateTrigger } from '../../../../src/condition-trigger/triggers/triggers/numeric-state'; import type { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; -import { createHASS, createStateEntity } from '../../../test-utils'; +import { + createHASS, + createStateEntity, + stubConnectedHomeAssistant, +} from '../../../test-utils'; import { createTriggerEvaluatorContext } from './test-utils'; const SENSOR = 'sensor.temperature'; @@ -163,6 +167,7 @@ describe('NumericStateTrigger', () => { it('should match the rendered value_template', async () => { const templateManager = new TemplateManager(); + stubConnectedHomeAssistant(); await templateManager.loadRenderer(); const { trigger, stateManager, callback } = create( diff --git a/tests/condition-trigger/triggers/triggers/template.test.ts b/tests/condition-trigger/triggers/triggers/template.test.ts index 738505a7..b6567e32 100644 --- a/tests/condition-trigger/triggers/triggers/template.test.ts +++ b/tests/condition-trigger/triggers/triggers/template.test.ts @@ -13,11 +13,16 @@ import { TemplateManager } from '../../../../src/card-controller/templates'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { TemplateTrigger } from '../../../../src/condition-trigger/triggers/triggers/template'; import type { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; -import { createHASS, createStateEntity } from '../../../test-utils'; +import { + createHASS, + createStateEntity, + stubConnectedHomeAssistant, +} from '../../../test-utils'; import { createTriggerEvaluatorContext } from './test-utils'; const templateManager = new TemplateManager(); beforeAll(async () => { + stubConnectedHomeAssistant(); await templateManager.loadRenderer(); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 84d45dda..115cd56b 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -758,6 +758,36 @@ export const createMockTemplateRenderer = (): TemplateManager => { return renderer; }; +// At import the real ha-nunjucks engine waits for a connected `home-assistant` +// element before finishing initialization, retrying on a timer that can outlive +// the test (and throw `document is not defined` once the environment is torn +// down). Test suites that load the real engine call this first, in a jsdom +// environment, so it initializes immediately as it does in a real Home +// Assistant frontend. Idempotent. +// +// Workaround for an upstream bug; remove once it is fixed: +// https://github.com/Nerwyn/ha-nunjucks/issues/11 +export const stubConnectedHomeAssistant = (): void => { + if (document.querySelector('home-assistant')) { + return; + } + const ha = document.createElement('home-assistant'); + Object.assign(ha, { + hass: { + // The readiness gate the engine polls for. + connected: true, + connection: { + connected: true, + // Awaited by the engine's label-registry fetch during init. + sendMessagePromise: () => Promise.resolve([]), + }, + language: 'en', + states: {}, + }, + }); + document.body.appendChild(ha); +}; + export const createCardAPI = (): CardController => { const api = mock(); diff --git a/yarn.lock b/yarn.lock index 41df4b59..fbf032ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2662,7 +2662,7 @@ __metadata: eslint-config-airbnb-base: "npm:^15.0.0" eslint-config-prettier: "npm:^9.1.0" eslint-plugin-import: "npm:^2.29.1" - ha-nunjucks: "npm:^1.3.0" + ha-nunjucks: "npm:~1.6.2" home-assistant-js-websocket: "npm:^9.4.0" js-yaml: "npm:^4.1.0" jsdom: "npm:^21.1.2" @@ -5786,14 +5786,15 @@ __metadata: languageName: node linkType: hard -"ha-nunjucks@npm:^1.3.0": - version: 1.3.0 - resolution: "ha-nunjucks@npm:1.3.0" +"ha-nunjucks@npm:~1.6.2": + version: 1.6.2 + resolution: "ha-nunjucks@npm:1.6.2" dependencies: home-assistant-js-websocket: "npm:latest" nunjucks: "npm:latest" + slugify: "npm:latest" ts-py-datetime: "npm:latest" - checksum: 10c0/9c346ee9866f748b41d98dbefefd46ddb0a43f7d563e4456e000047e273e5441e5a15321ba8684325a81d746a9e9c32c5c0f4f1a58637291babebcae45329442 + checksum: 10c0/f3953be5de5f27e7cf5c111bb9fc969e2e7a7732547c0549c88a5fed2480c12bdb058feff81e68059570831b1464cf26bf356df6f60dfcc904d16d2c6f844ee8 languageName: node linkType: hard @@ -10283,6 +10284,13 @@ __metadata: languageName: node linkType: hard +"slugify@npm:latest": + version: 1.6.9 + resolution: "slugify@npm:1.6.9" + checksum: 10c0/8473c566ae00c5db26bfbf6182a4a9478ab3c912a9c926e1fdb410736a77228bfca4fdc5c755b46fafa7d2d9900de482fd7a9bd5e5c91128c4743c2c072d88da + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0"