@flighthq/host-web 0.4.0-next.1836.147ae1e → 0.4.0-next.1884.6a2d46f

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.
@@ -1,3 +1,4 @@
1
1
  export * from './webCursor';
2
2
  export * from './webGlyphRasterizer';
3
+ export * from './webLoop';
3
4
  //# sourceMappingURL=contract.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC"}
1
+ {"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC;AACrC,cAAc,WAAW,CAAC"}
package/dist/contract.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './webCursor';
2
2
  export * from './webGlyphRasterizer';
3
+ export * from './webLoop';
3
4
  //# sourceMappingURL=contract.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"contract.js","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC"}
1
+ {"version":3,"file":"contract.js","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC;AACrC,cAAc,WAAW,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { createWebCursorBackend } from './webCursor';
2
2
  export { createWebGlyphRasterizerBackend, enableHostWebGlyphRasterizer } from './webGlyphRasterizer';
3
+ export { enableHostWebLoop } from './webLoop';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,+BAA+B,EAAE,4BAA4B,EAAE,MAAM,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,+BAA+B,EAAE,4BAA4B,EAAE,MAAM,sBAAsB,CAAC;AACrG,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { createWebCursorBackend } from './webCursor';
2
2
  export { createWebGlyphRasterizerBackend, enableHostWebGlyphRasterizer } from './webGlyphRasterizer';
3
+ export { enableHostWebLoop } from './webLoop';
3
4
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,+BAA+B,EAAE,4BAA4B,EAAE,MAAM,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,+BAA+B,EAAE,4BAA4B,EAAE,MAAM,sBAAsB,CAAC;AACrG,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"webGlyphRasterizer.d.ts","sourceRoot":"","sources":["../src/webGlyphRasterizer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAIV,sBAAsB,EACvB,MAAM,0BAA0B,CAAC;AAElC,wBAAgB,+BAA+B,IAAI,sBAAsB,CAkBxE;AAKD,wBAAgB,4BAA4B,IAAI,IAAI,CAMnD;AAsFD,wBAAgB,kCAAkC,IAAI,IAAI,CAEzD"}
1
+ {"version":3,"file":"webGlyphRasterizer.d.ts","sourceRoot":"","sources":["../src/webGlyphRasterizer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAIV,sBAAsB,EACvB,MAAM,0BAA0B,CAAC;AAElC,wBAAgB,+BAA+B,IAAI,sBAAsB,CA8BxE;AAED,wBAAgB,4BAA4B,IAAI,IAAI,CAInD;AAsED,wBAAgB,kCAAkC,IAAI,IAAI,CAEzD"}
@@ -1,36 +1,40 @@
1
- import { installGlyphRasterizerHostBackend } from '@flighthq/glyphatlas/contract';
1
+ import { installGlyphRasterizerHostBackend, observeGlyphRasterizerHostResult } from '@flighthq/glyphatlas/contract';
2
2
  export function createWebGlyphRasterizerBackend() {
3
3
  return {
4
4
  measureMetrics(options) {
5
5
  const context = _acquireGlyphRasterContext();
6
- if (context === null)
6
+ if (context === null) {
7
+ observeGlyphRasterizerHostResult('measureMetrics', false);
7
8
  return null;
9
+ }
8
10
  _applyGlyphRasterFont(context, options);
9
11
  const metrics = context.measureText('Hg');
10
12
  const ascent = metrics.fontBoundingBoxAscent;
11
13
  const descent = metrics.fontBoundingBoxDescent;
12
- if (!(ascent > 0) || !(descent >= 0))
14
+ if (!(ascent > 0) || !(descent >= 0)) {
15
+ observeGlyphRasterizerHostResult('measureMetrics', false);
13
16
  return null;
17
+ }
18
+ observeGlyphRasterizerHostResult('measureMetrics', true);
14
19
  return { ascent, descent, lineGap: 0 };
15
20
  },
16
21
  rasterize(codepoint, options) {
17
22
  const context = _acquireGlyphRasterContext();
18
- if (context === null)
23
+ if (context === null) {
24
+ observeGlyphRasterizerHostResult('rasterize', false);
19
25
  return null;
26
+ }
27
+ // Observe on context acquisition, not on result: null is valid for zero-ink glyphs (space).
28
+ observeGlyphRasterizerHostResult('rasterize', true);
20
29
  return _rasterizeGlyphOnContext(context, codepoint, options);
21
30
  },
22
31
  };
23
32
  }
24
- // Probes the runtime environment for a 2D canvas context. The enabler calls this once at
25
- // enable time to decide viability — the backend itself re-acquires on every call, but the
26
- // viability signal is locked at enable time so explain reports a stable answer.
27
33
  export function enableHostWebGlyphRasterizer() {
28
34
  if (_enabled)
29
35
  return;
30
36
  _enabled = true;
31
- const viable = _probeGlyphRasterizerViability();
32
- const backend = viable ? createWebGlyphRasterizerBackend() : _unavailableBackend;
33
- installGlyphRasterizerHostBackend(backend, viable);
37
+ installGlyphRasterizerHostBackend(createWebGlyphRasterizerBackend());
34
38
  }
35
39
  function _acquireGlyphRasterContext() {
36
40
  try {
@@ -55,24 +59,6 @@ function _applyGlyphRasterFont(context, options) {
55
59
  const fontWeight = options.fontWeight ?? 'normal';
56
60
  context.font = `${fontStyle} ${fontWeight} ${options.fontSize}px ${options.fontFamily}`;
57
61
  }
58
- function _probeGlyphRasterizerViability() {
59
- try {
60
- if (typeof OffscreenCanvas !== 'undefined') {
61
- const context = new OffscreenCanvas(1, 1).getContext('2d');
62
- if (context !== null)
63
- return true;
64
- }
65
- if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
66
- const context = document.createElement('canvas').getContext('2d');
67
- if (context !== null)
68
- return true;
69
- }
70
- }
71
- catch {
72
- return false;
73
- }
74
- return false;
75
- }
76
62
  function _rasterizeGlyphOnContext(context, codepoint, options) {
77
63
  const text = String.fromCodePoint(codepoint);
78
64
  _applyGlyphRasterFont(context, options);
@@ -112,11 +98,4 @@ export function resetHostWebGlyphRasterizerForTest() {
112
98
  _enabled = false;
113
99
  }
114
100
  let _enabled = false;
115
- // Installed when the environment lacks OffscreenCanvas/document/2D-context. A real object (not
116
- // null) so get returns it and explain reports layer:host + viability:runtime-api-unavailable.
117
- const _unavailableBackend = {
118
- rasterize() {
119
- return null;
120
- },
121
- };
122
101
  //# sourceMappingURL=webGlyphRasterizer.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"webGlyphRasterizer.js","sourceRoot":"","sources":["../src/webGlyphRasterizer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"webGlyphRasterizer.js","sourceRoot":"","sources":["../src/webGlyphRasterizer.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ export declare function enableHostWebLoop(): void;
2
+ export declare function resetHostWebLoopForTest(): void;
3
+ //# sourceMappingURL=webLoop.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webLoop.d.ts","sourceRoot":"","sources":["../src/webLoop.ts"],"names":[],"mappings":"AAGA,wBAAgB,iBAAiB,IAAI,IAAI,CA4BxC;AAED,wBAAgB,uBAAuB,IAAI,IAAI,CAE9C"}
@@ -0,0 +1,38 @@
1
+ import { createWebLoopBackend, installLoopHostBackend, observeLoopHostResult } from '@flighthq/application/contract';
2
+ export function enableHostWebLoop() {
3
+ if (_enabled)
4
+ return;
5
+ _enabled = true;
6
+ const inner = createWebLoopBackend();
7
+ const backend = {
8
+ requestFrame(callback) {
9
+ try {
10
+ const handle = inner.requestFrame(callback);
11
+ observeLoopHostResult('requestFrame', true);
12
+ return handle;
13
+ }
14
+ catch {
15
+ observeLoopHostResult('requestFrame', false);
16
+ return null;
17
+ }
18
+ },
19
+ cancelFrame(handle) {
20
+ try {
21
+ inner.cancelFrame(handle);
22
+ observeLoopHostResult('cancelFrame', true);
23
+ }
24
+ catch {
25
+ observeLoopHostResult('cancelFrame', false);
26
+ }
27
+ },
28
+ now() {
29
+ return inner.now();
30
+ },
31
+ };
32
+ installLoopHostBackend(backend);
33
+ }
34
+ export function resetHostWebLoopForTest() {
35
+ _enabled = false;
36
+ }
37
+ let _enabled = false;
38
+ //# sourceMappingURL=webLoop.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webLoop.js","sourceRoot":"","sources":["../src/webLoop.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AAGrH,MAAM,UAAU,iBAAiB;IAC/B,IAAI,QAAQ;QAAE,OAAO;IACrB,QAAQ,GAAG,IAAI,CAAC;IAChB,MAAM,KAAK,GAAG,oBAAoB,EAAE,CAAC;IACrC,MAAM,OAAO,GAAgB;QAC3B,YAAY,CAAC,QAAgC;YAC3C,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,KAAK,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;gBAC5C,qBAAqB,CAAC,cAAc,EAAE,IAAI,CAAC,CAAC;gBAC5C,OAAO,MAAM,CAAC;YAChB,CAAC;YAAC,MAAM,CAAC;gBACP,qBAAqB,CAAC,cAAc,EAAE,KAAK,CAAC,CAAC;gBAC7C,OAAO,IAAI,CAAC;YACd,CAAC;QACH,CAAC;QACD,WAAW,CAAC,MAAe;YACzB,IAAI,CAAC;gBACH,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;gBAC1B,qBAAqB,CAAC,aAAa,EAAE,IAAI,CAAC,CAAC;YAC7C,CAAC;YAAC,MAAM,CAAC;gBACP,qBAAqB,CAAC,aAAa,EAAE,KAAK,CAAC,CAAC;YAC9C,CAAC;QACH,CAAC;QACD,GAAG;YACD,OAAO,KAAK,CAAC,GAAG,EAAE,CAAC;QACrB,CAAC;KACF,CAAC;IACF,sBAAsB,CAAC,OAAO,CAAC,CAAC;AAClC,CAAC;AAED,MAAM,UAAU,uBAAuB;IACrC,QAAQ,GAAG,KAAK,CAAC;AACnB,CAAC;AAED,IAAI,QAAQ,GAAG,KAAK,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flighthq/host-web",
3
- "version": "0.4.0-next.1836.147ae1e",
3
+ "version": "0.4.0-next.1884.6a2d46f",
4
4
  "author": "Joshua Granick and other contributors",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,8 +38,9 @@
38
38
  "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
39
39
  },
40
40
  "dependencies": {
41
- "@flighthq/glyphatlas": "0.4.0-next.1836.147ae1e",
42
- "@flighthq/types": "0.4.0-next.1836.147ae1e"
41
+ "@flighthq/application": "0.4.0-next.1884.6a2d46f",
42
+ "@flighthq/glyphatlas": "0.4.0-next.1884.6a2d46f",
43
+ "@flighthq/types": "0.4.0-next.1884.6a2d46f"
43
44
  },
44
45
  "devDependencies": {
45
46
  "typescript": "^5.3.0"
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'fs';
1
+ import { existsSync, readFileSync, readdirSync } from 'fs';
2
2
  import { resolve } from 'path';
3
3
  import type { OutputChunk, RollupOutput } from 'rollup';
4
4
  import { build } from 'vite';
@@ -7,9 +7,17 @@ import { gzipSync } from 'zlib';
7
7
 
8
8
  const ROOT = resolve(__dirname, '../../..');
9
9
  const EVIDENCE_DIR = resolve(__dirname, '../evidence');
10
- const FIXTURES = ['control', 'capability', 'cursor', 'glyph', 'both'] as const;
11
10
 
12
- type FixtureName = (typeof FIXTURES)[number];
11
+ interface CapabilityEntry {
12
+ readonly fixture: string;
13
+ readonly modulePattern: string;
14
+ }
15
+
16
+ const CAPABILITIES: readonly CapabilityEntry[] = [
17
+ { fixture: 'cursor', modulePattern: 'webCursor' },
18
+ { fixture: 'glyph', modulePattern: 'webGlyphRasterizer' },
19
+ { fixture: 'loop', modulePattern: 'webLoop' },
20
+ ];
13
21
 
14
22
  interface FixtureResult {
15
23
  modules: string[];
@@ -21,7 +29,7 @@ interface FixtureResult {
21
29
  function resolveWorkspaceAliases(): Record<string, string> {
22
30
  const packagesDir = resolve(ROOT, 'packages');
23
31
  const alias: Record<string, string> = {};
24
- const entries = require('fs').readdirSync(packagesDir, { withFileTypes: true });
32
+ const entries = readdirSync(packagesDir, { withFileTypes: true });
25
33
  for (const entry of entries) {
26
34
  if (!entry.isDirectory()) continue;
27
35
  const pkgJsonPath = resolve(packagesDir, entry.name, 'package.json');
@@ -34,7 +42,7 @@ function resolveWorkspaceAliases(): Record<string, string> {
34
42
  return alias;
35
43
  }
36
44
 
37
- async function buildFixture(name: FixtureName): Promise<FixtureResult> {
45
+ async function buildFixture(name: string): Promise<FixtureResult> {
38
46
  const entry = resolve(EVIDENCE_DIR, `${name}.ts`);
39
47
  const alias = resolveWorkspaceAliases();
40
48
 
@@ -76,26 +84,22 @@ async function buildFixture(name: FixtureName): Promise<FixtureResult> {
76
84
  };
77
85
  }
78
86
 
79
- function sourceModules(modules: string[]): string[] {
87
+ function sourceModules(modules: readonly string[]): string[] {
80
88
  return modules.filter((m) => !m.includes('/evidence/'));
81
89
  }
82
90
 
83
- function hasHostWebModule(modules: string[]): boolean {
91
+ function hasHostWebModule(modules: readonly string[]): boolean {
84
92
  return sourceModules(modules).some((m) => m.includes('packages/host-web/'));
85
93
  }
86
94
 
87
- function hasGlyphModule(modules: string[]): boolean {
88
- return sourceModules(modules).some((m) => m.includes('webGlyphRasterizer'));
89
- }
90
-
91
- function hasCursorModule(modules: string[]): boolean {
92
- return sourceModules(modules).some((m) => m.includes('webCursor'));
95
+ function hasCapabilityModule(modules: readonly string[], pattern: string): boolean {
96
+ return sourceModules(modules).some((m) => m.includes(pattern));
93
97
  }
94
98
 
95
99
  describe('evidence: tree-shaking isolation', () => {
96
- const results = new Map<FixtureName, FixtureResult>();
100
+ const results = new Map<string, FixtureResult>();
97
101
 
98
- async function getFixture(name: FixtureName): Promise<FixtureResult> {
102
+ async function getFixture(name: string): Promise<FixtureResult> {
99
103
  let result = results.get(name);
100
104
  if (result === undefined) {
101
105
  result = await buildFixture(name);
@@ -111,37 +115,37 @@ describe('evidence: tree-shaking isolation', () => {
111
115
 
112
116
  it('capability fixture contains zero host-web modules', async () => {
113
117
  const { modules } = await getFixture('capability');
118
+ expect(sourceModules(modules).length).toBeGreaterThan(0);
114
119
  expect(hasHostWebModule(modules)).toBe(false);
115
120
  });
116
121
 
117
- it('cursor fixture excludes glyph rasterizer modules', async () => {
118
- const { modules } = await getFixture('cursor');
119
- expect(hasHostWebModule(modules)).toBe(true);
120
- expect(hasGlyphModule(modules)).toBe(false);
121
- });
122
-
123
- it('glyph fixture excludes cursor modules', async () => {
124
- const { modules } = await getFixture('glyph');
125
- expect(hasHostWebModule(modules)).toBe(true);
126
- expect(hasCursorModule(modules)).toBe(false);
127
- });
122
+ for (const cap of CAPABILITIES) {
123
+ for (const other of CAPABILITIES) {
124
+ if (other === cap) continue;
125
+ it(`${cap.fixture} fixture excludes ${other.fixture} modules`, async () => {
126
+ const { modules } = await getFixture(cap.fixture);
127
+ expect(sourceModules(modules).length).toBeGreaterThan(0);
128
+ expect(hasHostWebModule(modules)).toBe(true);
129
+ expect(hasCapabilityModule(modules, other.modulePattern)).toBe(false);
130
+ });
131
+ }
132
+ }
128
133
 
129
- it('both module set is within the union of cursor and glyph', async () => {
130
- const [cursorResult, glyphResult, bothResult] = await Promise.all([
131
- getFixture('cursor'),
132
- getFixture('glyph'),
133
- getFixture('both'),
134
- ]);
135
- const union = new Set([...sourceModules(cursorResult.modules), ...sourceModules(glyphResult.modules)]);
136
- const outsideUnion = sourceModules(bothResult.modules).filter((m) => !union.has(m));
134
+ it('combined module set is within the union of individual capabilities', async () => {
135
+ const individualResults = await Promise.all(CAPABILITIES.map((cap) => getFixture(cap.fixture)));
136
+ const combinedResult = await getFixture('combined');
137
+ const union = new Set(individualResults.flatMap((r) => sourceModules(r.modules)));
138
+ const outsideUnion = sourceModules(combinedResult.modules).filter((m) => !union.has(m));
137
139
  expect(outsideUnion).toEqual([]);
138
140
  });
139
141
 
140
- it('reports control-normalized sizes', async () => {
142
+ it('normalized subadditivity: S(combined) + (N-1)*S(control) <= sum(S(individual))', async () => {
141
143
  const control = await getFixture('control');
142
- const names: FixtureName[] = ['capability', 'cursor', 'glyph', 'both'];
143
- for (const name of names) {
144
- const fixture = await getFixture(name);
144
+ const combined = await getFixture('combined');
145
+ const individualResults = await Promise.all(CAPABILITIES.map((cap) => getFixture(cap.fixture)));
146
+ const n = CAPABILITIES.length;
147
+
148
+ for (const [name, fixture] of CAPABILITIES.map((cap, i) => [cap.fixture, individualResults[i]] as const)) {
145
149
  const rawDelta = fixture.rawBytes - control.rawBytes;
146
150
  const gzipDelta = fixture.gzipBytes - control.gzipBytes;
147
151
  console.log(
@@ -149,6 +153,20 @@ describe('evidence: tree-shaking isolation', () => {
149
153
  `gzip=${fixture.gzipBytes} (${gzipDelta >= 0 ? '+' : ''}${gzipDelta})`,
150
154
  );
151
155
  }
156
+ console.log(`combined: raw=${combined.rawBytes}, gzip=${combined.gzipBytes}`);
152
157
  console.log(`control: raw=${control.rawBytes}, gzip=${control.gzipBytes}`);
158
+
159
+ const sumRaw = individualResults.reduce((s, r) => s + r.rawBytes, 0);
160
+ const sumGzip = individualResults.reduce((s, r) => s + r.gzipBytes, 0);
161
+
162
+ for (const cap of CAPABILITIES) {
163
+ const individual = individualResults[CAPABILITIES.indexOf(cap)];
164
+ expect(individual.rawBytes).toBeGreaterThan(control.rawBytes);
165
+ }
166
+
167
+ expect(combined.rawBytes).toBeGreaterThan(Math.max(...individualResults.map((r) => r.rawBytes)));
168
+
169
+ expect(combined.rawBytes + (n - 1) * control.rawBytes).toBeLessThanOrEqual(sumRaw);
170
+ expect(combined.gzipBytes + (n - 1) * control.gzipBytes).toBeLessThanOrEqual(sumGzip);
153
171
  });
154
172
  });
@@ -21,12 +21,15 @@ describe('createWebGlyphRasterizerBackend', () => {
21
21
  expect(backend.measureMetrics).toBeTypeOf('function');
22
22
  });
23
23
 
24
- it('rasterize returns null when no canvas context is available', () => {
24
+ it('rasterize returns null and observes failure when no canvas context is available', () => {
25
25
  const backend = createWebGlyphRasterizerBackend();
26
+ installGlyphRasterizerHostBackend(backend);
26
27
  const saved = HTMLCanvasElement.prototype.getContext;
27
28
  HTMLCanvasElement.prototype.getContext = (() => null) as typeof HTMLCanvasElement.prototype.getContext;
28
29
  try {
29
30
  expect(backend.rasterize(65, { fontFamily: 'x', fontSize: 16 })).toBeNull();
31
+ expect(explainGlyphRasterizerBackend().viability).toBe('runtime-api-unavailable');
32
+ expect(explainGlyphRasterizerBackend().operation).toBe('rasterize');
30
33
  } finally {
31
34
  HTMLCanvasElement.prototype.getContext = saved;
32
35
  }
@@ -55,40 +58,85 @@ describe('enableHostWebGlyphRasterizer', () => {
55
58
  expect(first).toBe(second);
56
59
  });
57
60
 
58
- it('reports available when the canvas API is present', () => {
61
+ it('starts unobserved — no viability claimed until a real call', () => {
59
62
  enableHostWebGlyphRasterizer();
60
- expect(explainGlyphRasterizerBackend()).toEqual({ layer: 'host', viability: 'available' });
63
+ expect(explainGlyphRasterizerBackend()).toEqual({
64
+ conflict: false,
65
+ layer: 'host',
66
+ operation: null,
67
+ viability: 'unobserved',
68
+ });
61
69
  });
62
70
 
63
- it('reports runtime-api-unavailable when no canvas context can be acquired', () => {
71
+ it('observes available after a successful rasterize call', () => {
72
+ enableHostWebGlyphRasterizer();
73
+ const backend = getGlyphRasterizerBackend();
74
+ const result = backend.rasterize(65, { fontFamily: 'sans-serif', fontSize: 16 });
75
+ if (result !== null) {
76
+ expect(explainGlyphRasterizerBackend()).toEqual({
77
+ conflict: false,
78
+ layer: 'host',
79
+ operation: 'rasterize',
80
+ viability: 'available',
81
+ });
82
+ }
83
+ });
84
+
85
+ it('observes runtime-api-unavailable when context returns null', () => {
64
86
  const saved = HTMLCanvasElement.prototype.getContext;
65
87
  HTMLCanvasElement.prototype.getContext = (() => null) as typeof HTMLCanvasElement.prototype.getContext;
66
88
  try {
67
89
  enableHostWebGlyphRasterizer();
90
+ const backend = getGlyphRasterizerBackend();
91
+ backend.rasterize(65, { fontFamily: 'x', fontSize: 16 });
68
92
  } finally {
69
93
  HTMLCanvasElement.prototype.getContext = saved;
70
94
  }
71
- expect(explainGlyphRasterizerBackend()).toEqual({ layer: 'host', viability: 'runtime-api-unavailable' });
95
+ expect(explainGlyphRasterizerBackend()).toEqual({
96
+ conflict: false,
97
+ layer: 'host',
98
+ operation: 'rasterize',
99
+ viability: 'runtime-api-unavailable',
100
+ });
72
101
  });
73
102
 
74
- it('installs a real backend object even when runtime API is unavailable', () => {
103
+ it('reflects recovery: broken environment then restored', () => {
104
+ enableHostWebGlyphRasterizer();
105
+ const backend = getGlyphRasterizerBackend();
106
+
107
+ // Break the environment — rasterize reports failure
75
108
  const saved = HTMLCanvasElement.prototype.getContext;
76
109
  HTMLCanvasElement.prototype.getContext = (() => null) as typeof HTMLCanvasElement.prototype.getContext;
77
- try {
78
- enableHostWebGlyphRasterizer();
79
- } finally {
80
- HTMLCanvasElement.prototype.getContext = saved;
110
+ backend.rasterize(65, { fontFamily: 'x', fontSize: 16 });
111
+ expect(explainGlyphRasterizerBackend().viability).toBe('runtime-api-unavailable');
112
+
113
+ // Restore — next call reports success
114
+ HTMLCanvasElement.prototype.getContext = saved;
115
+ const result = backend.rasterize(65, { fontFamily: 'sans-serif', fontSize: 16 });
116
+ if (result !== null) {
117
+ expect(explainGlyphRasterizerBackend().viability).toBe('available');
81
118
  }
82
- const backend = getGlyphRasterizerBackend();
83
- expect(backend.rasterize).toBeTypeOf('function');
84
- expect(backend.rasterize(65, { fontFamily: 'x', fontSize: 16 })).toBeNull();
85
119
  });
86
120
 
87
121
  it('does not conflict with a prior install of the same backend via direct install', () => {
88
122
  enableHostWebGlyphRasterizer();
89
123
  const backend = getGlyphRasterizerBackend();
90
- installGlyphRasterizerHostBackend(backend, true);
91
- expect(explainGlyphRasterizerBackend().viability).not.toBe('provider-conflict');
124
+ installGlyphRasterizerHostBackend(backend);
125
+ expect(explainGlyphRasterizerBackend().conflict).toBe(false);
126
+ });
127
+
128
+ it('observes available even when rasterize returns null for a zero-ink glyph', () => {
129
+ enableHostWebGlyphRasterizer();
130
+ const backend = getGlyphRasterizerBackend();
131
+ // Space (codepoint 32) has no visible pixels — rasterize returns null, but the API is working
132
+ const result = backend.rasterize(32, { fontFamily: 'sans-serif', fontSize: 16 });
133
+ expect(result).toBeNull();
134
+ expect(explainGlyphRasterizerBackend()).toEqual({
135
+ conflict: false,
136
+ layer: 'host',
137
+ operation: 'rasterize',
138
+ viability: 'available',
139
+ });
92
140
  });
93
141
  });
94
142
 
@@ -0,0 +1,123 @@
1
+ import { explainLoopBackend, getLoopBackend, resetLoopBackendForTest } from '@flighthq/application/contract';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { enableHostWebLoop, resetHostWebLoopForTest } from './webLoop';
5
+
6
+ describe('enableHostWebLoop', () => {
7
+ afterEach(() => {
8
+ resetLoopBackendForTest();
9
+ resetHostWebLoopForTest();
10
+ vi.restoreAllMocks();
11
+ });
12
+
13
+ it('installs a host backend so get returns non-null', () => {
14
+ enableHostWebLoop();
15
+ const backend = getLoopBackend();
16
+ expect(backend).not.toBeNull();
17
+ expect(backend!.now()).toBeTypeOf('number');
18
+ expect(explainLoopBackend().layer).toBe('host');
19
+ });
20
+
21
+ it('is idempotent — second call preserves provider identity', () => {
22
+ enableHostWebLoop();
23
+ const first = getLoopBackend();
24
+ enableHostWebLoop();
25
+ const second = getLoopBackend();
26
+ expect(first).toBe(second);
27
+ });
28
+
29
+ it('starts unobserved — no viability claimed until a real call', () => {
30
+ enableHostWebLoop();
31
+ expect(explainLoopBackend()).toEqual({
32
+ conflict: false,
33
+ layer: 'host',
34
+ operation: null,
35
+ viability: 'unobserved',
36
+ });
37
+ });
38
+
39
+ it('observes available after requestFrame call', () => {
40
+ enableHostWebLoop();
41
+ const backend = getLoopBackend()!;
42
+ const handle = backend.requestFrame(() => {});
43
+ expect(handle).toBeTypeOf('number');
44
+ expect(explainLoopBackend()).toEqual({
45
+ conflict: false,
46
+ layer: 'host',
47
+ operation: 'requestFrame',
48
+ viability: 'available',
49
+ });
50
+ });
51
+
52
+ it('every requestFrame re-observes — not one-shot', () => {
53
+ enableHostWebLoop();
54
+ const backend = getLoopBackend()!;
55
+ backend.requestFrame(() => {});
56
+ backend.requestFrame(() => {});
57
+ expect(explainLoopBackend().operation).toBe('requestFrame');
58
+ expect(explainLoopBackend().viability).toBe('available');
59
+ });
60
+
61
+ it('cancelFrame observes independently from requestFrame', () => {
62
+ enableHostWebLoop();
63
+ const backend = getLoopBackend()!;
64
+ const handle = backend.requestFrame(() => {});
65
+ expect(explainLoopBackend().operation).toBe('requestFrame');
66
+ backend.cancelFrame(handle);
67
+ expect(explainLoopBackend().operation).toBe('cancelFrame');
68
+ expect(explainLoopBackend().viability).toBe('available');
69
+ });
70
+
71
+ it('loss then recovery — requestFrame failure reverts to available on success', () => {
72
+ enableHostWebLoop();
73
+ const backend = getLoopBackend()!;
74
+ backend.requestFrame(() => {});
75
+ expect(explainLoopBackend().viability).toBe('available');
76
+
77
+ const raf = vi.fn().mockImplementation(() => {
78
+ throw new Error('rAF unavailable');
79
+ });
80
+ vi.stubGlobal('requestAnimationFrame', raf);
81
+ backend.requestFrame(() => {});
82
+ expect(explainLoopBackend().viability).toBe('runtime-api-unavailable');
83
+ expect(explainLoopBackend().operation).toBe('requestFrame');
84
+
85
+ vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(99));
86
+ backend.requestFrame(() => {});
87
+ expect(explainLoopBackend().viability).toBe('available');
88
+ });
89
+
90
+ it('cancelFrame failure observes unavailable', () => {
91
+ enableHostWebLoop();
92
+ const backend = getLoopBackend()!;
93
+ backend.requestFrame(() => {});
94
+
95
+ vi.stubGlobal(
96
+ 'cancelAnimationFrame',
97
+ vi.fn().mockImplementation(() => {
98
+ throw new Error('cAF unavailable');
99
+ }),
100
+ );
101
+ backend.cancelFrame(1);
102
+ expect(explainLoopBackend().operation).toBe('cancelFrame');
103
+ expect(explainLoopBackend().viability).toBe('runtime-api-unavailable');
104
+ });
105
+
106
+ it('now returns a positive number', () => {
107
+ enableHostWebLoop();
108
+ const backend = getLoopBackend()!;
109
+ expect(backend.now()).toBeGreaterThanOrEqual(0);
110
+ });
111
+ });
112
+
113
+ describe('resetHostWebLoopForTest', () => {
114
+ afterEach(resetLoopBackendForTest);
115
+
116
+ it('clears the enabler flag so a subsequent enable re-installs', () => {
117
+ enableHostWebLoop();
118
+ resetHostWebLoopForTest();
119
+ resetLoopBackendForTest();
120
+ enableHostWebLoop();
121
+ expect(explainLoopBackend().layer).toBe('host');
122
+ });
123
+ });