@malloy-publisher/server 0.0.215 → 0.0.217
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/api-doc.yaml +30 -4
- package/dist/app/assets/{EnvironmentPage-CuO6sty5.js → EnvironmentPage-BX71Wsun.js} +1 -1
- package/dist/app/assets/{HomePage-u3vxROIF.js → HomePage-Bnp4Puv4.js} +1 -1
- package/dist/app/assets/{MainPage-Bt2sYLbr.js → MainPage-chkqUlI0.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-B1rj61zs.js → MaterializationsPage-DG4e_wRd.js} +1 -1
- package/dist/app/assets/{ModelPage-BpvONvSR.js → ModelPage-DbLU-ABs.js} +1 -1
- package/dist/app/assets/{PackagePage-DHNcwboW.js → PackagePage-EvWf3VZ4.js} +1 -1
- package/dist/app/assets/{RouteError-D1vhLJvr.js → RouteError-POj8kImc.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CZmud-yI.js → WorkbookPage-Bkq3C1MS.js} +1 -1
- package/dist/app/assets/{core-XtBSnW2U.es-DE88sVsZ.js → core-DiLT6QvL.es-Jn6sdy15.js} +1 -1
- package/dist/app/assets/{index-BQdOB6m9.js → index-C_AT6ZZw.js} +1 -1
- package/dist/app/assets/{index-CYf2akGH.js → index-bAdd7U9-.js} +1 -1
- package/dist/app/assets/{index-BlnQtDZj.js → index-gjr27uMq.js} +73 -73
- package/dist/app/assets/{index.umd-BdQ0R4hx.js → index.umd-BxzPw_1E.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +33 -7
- package/package.json +1 -1
- package/src/server.ts +55 -1
- package/src/service/build_plan.spec.ts +21 -0
- package/src/service/build_plan.ts +14 -0
- package/src/service/environment.ts +7 -0
- package/src/service/materialization_schedule_surface.spec.ts +103 -0
- package/src/service/package.ts +10 -1
- package/tests/fixtures/html-pages-test/public/barehtml.html +4 -0
- package/tests/fixtures/html-pages-test/public/bodymeta.html +8 -0
- package/tests/fixtures/html-pages-test/public/fitcomment.html +11 -0
- package/tests/fixtures/html-pages-test/public/nohead.html +8 -0
- package/tests/fixtures/html-pages-test/public/notfit.html +13 -0
- package/tests/fixtures/html-pages-test/public/slides.html +12 -0
- package/tests/fixtures/html-pages-test/public/unterminated.html +10 -0
- package/tests/integration/html_pages/html_pages.integration.spec.ts +63 -1
package/src/server.ts
CHANGED
|
@@ -491,7 +491,32 @@ type PageItem = {
|
|
|
491
491
|
packageName: string;
|
|
492
492
|
path: string;
|
|
493
493
|
title: string;
|
|
494
|
+
fit?: "viewport";
|
|
494
495
|
};
|
|
496
|
+
|
|
497
|
+
// The spots in an HTML head where a "<meta ...>" literal would NOT be a live
|
|
498
|
+
// tag: HTML comments (terminated or unterminated) and raw-text/RCDATA elements
|
|
499
|
+
// (script/style/title/textarea). One alternation so a single .replace covers
|
|
500
|
+
// them all (and so the fixpoint loop below applies one self-referential
|
|
501
|
+
// replace, which is the complete-sanitization shape CodeQL recognizes).
|
|
502
|
+
const NON_TAG_TEXT_PATTERN =
|
|
503
|
+
/<!--[\s\S]*?-->|<!--[\s\S]*$|<(script|style|title|textarea)\b[\s\S]*?<\/\1\s*>/gi;
|
|
504
|
+
|
|
505
|
+
// Remove those matches until the string stops changing. A single pass is
|
|
506
|
+
// incomplete because removing one match can splice the surrounding text into a
|
|
507
|
+
// new one (CWE-116), so re-apply the same pattern to its own output until a
|
|
508
|
+
// fixpoint. Each pass only deletes, so the string strictly shrinks and the loop
|
|
509
|
+
// terminates, bounded by the input length (callers pass at most the first 4KB).
|
|
510
|
+
function stripNonTagText(input: string): string {
|
|
511
|
+
let current = input;
|
|
512
|
+
let previous: string;
|
|
513
|
+
do {
|
|
514
|
+
previous = current;
|
|
515
|
+
current = current.replace(NON_TAG_TEXT_PATTERN, "");
|
|
516
|
+
} while (current !== previous);
|
|
517
|
+
return current;
|
|
518
|
+
}
|
|
519
|
+
|
|
495
520
|
async function listPackagePages(
|
|
496
521
|
environmentName: string,
|
|
497
522
|
packageName: string,
|
|
@@ -539,8 +564,9 @@ async function listPackagePages(
|
|
|
539
564
|
(entry.name.endsWith(".html") || entry.name.endsWith(".htm"))
|
|
540
565
|
) {
|
|
541
566
|
const rel = path.relative(publicRoot, full).replace(/\\/g, "/");
|
|
542
|
-
// Cheap
|
|
567
|
+
// Cheap metadata extraction: read first 4KB and grep the <head>.
|
|
543
568
|
let title = rel;
|
|
569
|
+
let fit: "viewport" | undefined;
|
|
544
570
|
try {
|
|
545
571
|
const fh = await fs.open(full, "r");
|
|
546
572
|
try {
|
|
@@ -549,6 +575,33 @@ async function listPackagePages(
|
|
|
549
575
|
const head = buf.slice(0, bytesRead).toString("utf8");
|
|
550
576
|
const m = head.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
551
577
|
if (m) title = m[1].trim();
|
|
578
|
+
// Full-screen apps (e.g. slide decks) opt into a viewport-fill
|
|
579
|
+
// embed with <meta name="publisher:fit" content="viewport">.
|
|
580
|
+
// FIRST strip the spots where the literal string is NOT a live
|
|
581
|
+
// tag (comments, script/style/title/textarea), THEN look at the
|
|
582
|
+
// <head> region only (up to </head> or <body>). Order matters:
|
|
583
|
+
// stripping before locating the boundary keeps a literal
|
|
584
|
+
// "<body>"/"</head>" inside a comment or <script> from
|
|
585
|
+
// truncating the scan and hiding a real tag. What's left and
|
|
586
|
+
// matches is a genuine <meta> the browser would honor too, so a
|
|
587
|
+
// documented/commented example or a string in a code block
|
|
588
|
+
// can't opt the page in. Match by name (attribute order/quoting
|
|
589
|
+
// vary), then confirm content="viewport"; the [\s"'] before
|
|
590
|
+
// `name` keeps `data-name="publisher:fit"` out. Like the title,
|
|
591
|
+
// the tag must sit within the first 4KB.
|
|
592
|
+
const cleaned = stripNonTagText(head);
|
|
593
|
+
const headEnd = cleaned.search(/<\/head\s*>|<body[\s>]/i);
|
|
594
|
+
const headTags =
|
|
595
|
+
headEnd === -1 ? cleaned : cleaned.slice(0, headEnd);
|
|
596
|
+
const fitMeta = headTags.match(
|
|
597
|
+
/<meta\b[^>]*[\s"']name\s*=\s*["']publisher:fit["'][^>]*>/i,
|
|
598
|
+
);
|
|
599
|
+
if (
|
|
600
|
+
fitMeta &&
|
|
601
|
+
/\bcontent\s*=\s*["']\s*viewport\s*["']/i.test(fitMeta[0])
|
|
602
|
+
) {
|
|
603
|
+
fit = "viewport";
|
|
604
|
+
}
|
|
552
605
|
} finally {
|
|
553
606
|
await fh.close();
|
|
554
607
|
}
|
|
@@ -560,6 +613,7 @@ async function listPackagePages(
|
|
|
560
613
|
packageName,
|
|
561
614
|
path: rel,
|
|
562
615
|
title,
|
|
616
|
+
fit,
|
|
563
617
|
});
|
|
564
618
|
}
|
|
565
619
|
}
|
|
@@ -229,6 +229,27 @@ describe("deriveBuildPlan", () => {
|
|
|
229
229
|
|
|
230
230
|
expect(Object.keys(plan.sources)).toEqual(["a@m"]);
|
|
231
231
|
});
|
|
232
|
+
|
|
233
|
+
it("carries the per-source package-relative modelPath", () => {
|
|
234
|
+
const a = fakeSource({ name: "a", buildId: "bid-a" });
|
|
235
|
+
const b = fakeSource({ name: "b", buildId: "bid-b" });
|
|
236
|
+
const plan = deriveBuildPlan(
|
|
237
|
+
[
|
|
238
|
+
{
|
|
239
|
+
connectionName: "duckdb",
|
|
240
|
+
nodes: [[{ sourceID: "a@m", dependsOn: [] }]],
|
|
241
|
+
},
|
|
242
|
+
] as unknown as Parameters<typeof deriveBuildPlan>[0],
|
|
243
|
+
{ "a@m": a, "b@m": b },
|
|
244
|
+
{ duckdb: "dig" },
|
|
245
|
+
undefined,
|
|
246
|
+
{ "a@m": "rollup.malloy" },
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// Mapped source gets its model path; an unmapped source stays undefined.
|
|
250
|
+
expect(plan.sources["a@m"].modelPath).toBe("rollup.malloy");
|
|
251
|
+
expect(plan.sources["b@m"].modelPath).toBeUndefined();
|
|
252
|
+
});
|
|
232
253
|
});
|
|
233
254
|
|
|
234
255
|
describe("compilePackageBuildPlan", () => {
|
|
@@ -42,6 +42,13 @@ export interface CompiledBuildPlan {
|
|
|
42
42
|
sources: Record<string, PersistSource>;
|
|
43
43
|
connectionDigests: Record<string, string>;
|
|
44
44
|
connections: Map<string, MalloyConnection>;
|
|
45
|
+
/**
|
|
46
|
+
* sourceID -> the package-relative path of the `.malloy` model that declares
|
|
47
|
+
* the source (the only place that mapping is known, since a sourceID's
|
|
48
|
+
* embedded modelURL is an absolute `file://` path with no package boundary).
|
|
49
|
+
* Optional so existing fixtures/callers that don't track it still typecheck.
|
|
50
|
+
*/
|
|
51
|
+
sourceModelPaths?: Record<string, string>;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
/** Output columns of a persist source, degrading to [] if unavailable. */
|
|
@@ -192,6 +199,7 @@ export async function compilePackageBuildPlan(
|
|
|
192
199
|
): Promise<CompiledBuildPlan> {
|
|
193
200
|
const allGraphs: MalloyBuildGraph[] = [];
|
|
194
201
|
const allSources: Record<string, PersistSource> = {};
|
|
202
|
+
const sourceModelPaths: Record<string, string> = {};
|
|
195
203
|
|
|
196
204
|
for (const modelPath of pkg.getModelPaths()) {
|
|
197
205
|
// Only `.malloy` models declare persist sources. Skip `.malloynb`
|
|
@@ -225,6 +233,7 @@ export async function compilePackageBuildPlan(
|
|
|
225
233
|
allGraphs.push(...buildPlan.graphs);
|
|
226
234
|
for (const [sourceID, source] of Object.entries(buildPlan.sources)) {
|
|
227
235
|
allSources[sourceID] = source;
|
|
236
|
+
sourceModelPaths[sourceID] = modelPath;
|
|
228
237
|
}
|
|
229
238
|
}
|
|
230
239
|
|
|
@@ -256,6 +265,7 @@ export async function compilePackageBuildPlan(
|
|
|
256
265
|
sources: allSources,
|
|
257
266
|
connectionDigests,
|
|
258
267
|
connections,
|
|
268
|
+
sourceModelPaths,
|
|
259
269
|
};
|
|
260
270
|
}
|
|
261
271
|
|
|
@@ -265,6 +275,7 @@ export function deriveBuildPlan(
|
|
|
265
275
|
sources: Record<string, PersistSource>,
|
|
266
276
|
connectionDigests: Record<string, string>,
|
|
267
277
|
sourceNames?: string[],
|
|
278
|
+
sourceModelPaths?: Record<string, string>,
|
|
268
279
|
): BuildPlan {
|
|
269
280
|
const include = sourceNames ? new Set(sourceNames) : null;
|
|
270
281
|
|
|
@@ -290,6 +301,7 @@ export function deriveBuildPlan(
|
|
|
290
301
|
sql: source.getSQL(),
|
|
291
302
|
columns: deriveColumns(source),
|
|
292
303
|
annotationFields: deriveAnnotationFields(source),
|
|
304
|
+
modelPath: sourceModelPaths?.[sourceID],
|
|
293
305
|
};
|
|
294
306
|
}
|
|
295
307
|
|
|
@@ -313,5 +325,7 @@ export async function computePackageBuildPlan(
|
|
|
313
325
|
compiled.graphs,
|
|
314
326
|
compiled.sources,
|
|
315
327
|
compiled.connectionDigests,
|
|
328
|
+
undefined,
|
|
329
|
+
compiled.sourceModelPaths,
|
|
316
330
|
);
|
|
317
331
|
}
|
|
@@ -1340,6 +1340,13 @@ export class Environment {
|
|
|
1340
1340
|
explores,
|
|
1341
1341
|
queryableSources,
|
|
1342
1342
|
manifestLocation,
|
|
1343
|
+
// Carry the manifest-derived materialization policy through the
|
|
1344
|
+
// PATCH. `setPackageMetadata` replaces the whole object, so omitting
|
|
1345
|
+
// this wipes the in-memory `materialization` until the next reload —
|
|
1346
|
+
// which makes a later getPackage report no schedule and lets the
|
|
1347
|
+
// control plane misread the gap as a schedule removal. It is not a
|
|
1348
|
+
// PATCH-editable field, so always preserve the existing value.
|
|
1349
|
+
materialization: existing.materialization,
|
|
1343
1350
|
});
|
|
1344
1351
|
|
|
1345
1352
|
// Strict-reject, symmetric with the publish path
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import * as fs from "fs/promises";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
|
|
6
|
+
import { Environment } from "./environment";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The publisher must surface a non-null `materialization` object on a loaded
|
|
10
|
+
* package's metadata so the control plane can treat object-present as the
|
|
11
|
+
* authoritative manifest policy ("this is what the manifest says") and
|
|
12
|
+
* object-absent as "metadata not loaded this request" — never as a schedule
|
|
13
|
+
* removal. A metadata PATCH must not drop that policy from the in-memory
|
|
14
|
+
* metadata. Regression coverage for the re-materialization schedule self-wipe
|
|
15
|
+
* (docs/bugs/materialization-schedule-self-wipe.md).
|
|
16
|
+
*
|
|
17
|
+
* Runs against a real `Environment` + real `Package.create` over temp dirs.
|
|
18
|
+
*/
|
|
19
|
+
describe("materialization schedule surfacing", () => {
|
|
20
|
+
const MODEL = `source: ones is duckdb.sql("SELECT 1 as x")\n`;
|
|
21
|
+
let rootDir: string;
|
|
22
|
+
let envPath: string;
|
|
23
|
+
|
|
24
|
+
async function writePackageDir(
|
|
25
|
+
manifest: Record<string, unknown>,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const dir = path.join(envPath, "pkg");
|
|
28
|
+
await fs.mkdir(dir, { recursive: true });
|
|
29
|
+
await fs.writeFile(
|
|
30
|
+
path.join(dir, "publisher.json"),
|
|
31
|
+
JSON.stringify({ name: "pkg", description: "fixture", ...manifest }),
|
|
32
|
+
);
|
|
33
|
+
await fs.writeFile(path.join(dir, "model.malloy"), MODEL);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "publisher-mat-"));
|
|
38
|
+
envPath = path.join(rootDir, "env");
|
|
39
|
+
await fs.mkdir(envPath, { recursive: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it(
|
|
47
|
+
"surfaces the manifest's materialization.schedule on load",
|
|
48
|
+
async () => {
|
|
49
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
50
|
+
await writePackageDir({ materialization: { schedule: "0 6 * * *" } });
|
|
51
|
+
await env.addPackage("pkg");
|
|
52
|
+
|
|
53
|
+
const pkg = await env.getPackage("pkg", false);
|
|
54
|
+
expect(pkg.getPackageMetadata().materialization).toEqual({
|
|
55
|
+
schedule: "0 6 * * *",
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
{ timeout: 20000 },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
it(
|
|
62
|
+
"surfaces a non-null materialization object even when the manifest declares none",
|
|
63
|
+
async () => {
|
|
64
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
65
|
+
await writePackageDir({});
|
|
66
|
+
await env.addPackage("pkg");
|
|
67
|
+
|
|
68
|
+
// Object present with a null schedule — NOT a null object — so the
|
|
69
|
+
// control plane reads it as an authoritative "no schedule declared",
|
|
70
|
+
// not as "metadata unavailable".
|
|
71
|
+
const pkg = await env.getPackage("pkg", false);
|
|
72
|
+
expect(pkg.getPackageMetadata().materialization).toEqual({
|
|
73
|
+
schedule: null,
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
{ timeout: 20000 },
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
it(
|
|
80
|
+
"preserves the materialization policy across a metadata PATCH",
|
|
81
|
+
async () => {
|
|
82
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
83
|
+
await writePackageDir({ materialization: { schedule: "0 6 * * *" } });
|
|
84
|
+
await env.addPackage("pkg");
|
|
85
|
+
|
|
86
|
+
// A description-only PATCH must not wipe the schedule from the
|
|
87
|
+
// in-memory metadata. The bug: setPackageMetadata replaces the whole
|
|
88
|
+
// object, so a later getPackage reported no schedule and the control
|
|
89
|
+
// plane misread the gap as a removal and cleared the cadence.
|
|
90
|
+
const updated = await env.updatePackage("pkg", {
|
|
91
|
+
name: "pkg",
|
|
92
|
+
description: "updated",
|
|
93
|
+
});
|
|
94
|
+
expect(updated.materialization).toEqual({ schedule: "0 6 * * *" });
|
|
95
|
+
|
|
96
|
+
const pkg = await env.getPackage("pkg", false);
|
|
97
|
+
expect(pkg.getPackageMetadata().materialization).toEqual({
|
|
98
|
+
schedule: "0 6 * * *",
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
{ timeout: 20000 },
|
|
102
|
+
);
|
|
103
|
+
});
|
package/src/service/package.ts
CHANGED
|
@@ -331,7 +331,16 @@ export class Package {
|
|
|
331
331
|
explores: outcome.packageMetadata.explores,
|
|
332
332
|
queryableSources: outcome.packageMetadata.queryableSources,
|
|
333
333
|
manifestLocation: outcome.packageMetadata.manifestLocation ?? null,
|
|
334
|
-
|
|
334
|
+
// Always surface a non-null `materialization` object once the package
|
|
335
|
+
// has loaded (schedule null when the manifest declares no policy). The
|
|
336
|
+
// control plane treats object-present as the authoritative "this is
|
|
337
|
+
// what the manifest says" signal and object-absent as "metadata not
|
|
338
|
+
// available this request" — so it must never be dropped to null on a
|
|
339
|
+
// successfully loaded package, or the CP can misread a transient
|
|
340
|
+
// absence as a schedule removal. See `parsePackageMaterialization`.
|
|
341
|
+
materialization: outcome.packageMetadata.materialization ?? {
|
|
342
|
+
schedule: null,
|
|
343
|
+
},
|
|
335
344
|
};
|
|
336
345
|
|
|
337
346
|
// Build live `Model`s from worker output. Any per-model compile
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<!-- this deck does <body> styling tricks; the literal must not truncate the scan -->
|
|
5
|
+
<meta name="publisher:fit" content="viewport" />
|
|
6
|
+
<title>Fit After Comment</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body style="height: 100vh; margin: 0; overflow: hidden">
|
|
9
|
+
<section style="height: 100%">A full-screen slide</section>
|
|
10
|
+
</body>
|
|
11
|
+
</html>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta data-name="publisher:fit" content="viewport" />
|
|
7
|
+
<title>Tall Dashboard</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<h1>A normal dashboard that hugs its content</h1>
|
|
11
|
+
<!-- shown in body, must NOT opt in: <meta name="publisher:fit" content="viewport"> -->
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="publisher:fit" content="viewport" />
|
|
7
|
+
<title>FY27 Slide Deck</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body style="height: 100vh; margin: 0; overflow: hidden">
|
|
10
|
+
<section style="height: 100%">A full-screen slide</section>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -66,6 +66,7 @@ interface PageItem {
|
|
|
66
66
|
packageName?: string;
|
|
67
67
|
path?: string;
|
|
68
68
|
title?: string;
|
|
69
|
+
fit?: "viewport";
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
// Creating a symlink that escapes the package needs privileges the Windows CI
|
|
@@ -322,7 +323,17 @@ describe("In-package HTML data apps (E2E)", () => {
|
|
|
322
323
|
const paths = pages.map((p) => p.path).sort();
|
|
323
324
|
// Only HTML files under public/ are listed; the toEqual pins the exact
|
|
324
325
|
// set, so non-public files (manifest, models, data) can't appear.
|
|
325
|
-
expect(paths).toEqual([
|
|
326
|
+
expect(paths).toEqual([
|
|
327
|
+
"barehtml.html",
|
|
328
|
+
"bodymeta.html",
|
|
329
|
+
"fitcomment.html",
|
|
330
|
+
"index.html",
|
|
331
|
+
"nohead.html",
|
|
332
|
+
"notfit.html",
|
|
333
|
+
"slides.html",
|
|
334
|
+
"sub/page2.html",
|
|
335
|
+
"unterminated.html",
|
|
336
|
+
]);
|
|
326
337
|
|
|
327
338
|
const index = pages.find((p) => p.path === "index.html");
|
|
328
339
|
expect(index?.title).toBe("Carrier Dashboard");
|
|
@@ -332,6 +343,57 @@ describe("In-package HTML data apps (E2E)", () => {
|
|
|
332
343
|
);
|
|
333
344
|
});
|
|
334
345
|
|
|
346
|
+
it("surfaces fit=viewport only for pages that opt in via <meta name=publisher:fit>", async () => {
|
|
347
|
+
const res = await fetch(apiUrl("/pages"));
|
|
348
|
+
expect(res.status).toBe(200);
|
|
349
|
+
const pages = (await res.json()) as PageItem[];
|
|
350
|
+
|
|
351
|
+
// slides.html opts in with <meta name="publisher:fit" content="viewport">
|
|
352
|
+
// (alongside a standard charset + viewport meta) → fill the embedded viewer.
|
|
353
|
+
expect(pages.find((p) => p.path === "slides.html")?.fit).toBe("viewport");
|
|
354
|
+
|
|
355
|
+
// fitcomment.html has a real <meta publisher:fit> AFTER a comment that
|
|
356
|
+
// contains the literal "<body>"; stripping comments before locating the
|
|
357
|
+
// </head>/<body> boundary must keep that literal from truncating the scan
|
|
358
|
+
// and hiding the real tag (it must still opt in).
|
|
359
|
+
expect(pages.find((p) => p.path === "fitcomment.html")?.fit).toBe(
|
|
360
|
+
"viewport",
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
// notfit.html carries the STANDARD <meta name="viewport"> (device-width),
|
|
364
|
+
// a decoy <meta data-name="publisher:fit"> in <head>, and the real tag as
|
|
365
|
+
// text in a <body> comment. None is a genuine <head> name=publisher:fit,
|
|
366
|
+
// so it must NOT be mistaken for an opt-in.
|
|
367
|
+
expect(pages.find((p) => p.path === "notfit.html")?.fit).toBeUndefined();
|
|
368
|
+
|
|
369
|
+
// A page with no relevant meta keeps content-height auto-sizing (the
|
|
370
|
+
// field is omitted, never `null`/`"content"`).
|
|
371
|
+
expect(pages.find((p) => p.path === "index.html")?.fit).toBeUndefined();
|
|
372
|
+
|
|
373
|
+
// nohead.html omits the optional </head> end tag and shows the tag in a
|
|
374
|
+
// <body> comment; the scan stops at <body>, so it must NOT opt in.
|
|
375
|
+
expect(pages.find((p) => p.path === "nohead.html")?.fit).toBeUndefined();
|
|
376
|
+
|
|
377
|
+
// barehtml.html omits BOTH </head> and <body> and shows the tag only in
|
|
378
|
+
// an HTML comment; comment-stripping must keep it from opting in.
|
|
379
|
+
expect(
|
|
380
|
+
pages.find((p) => p.path === "barehtml.html")?.fit,
|
|
381
|
+
).toBeUndefined();
|
|
382
|
+
|
|
383
|
+
// bodymeta.html has a REAL (uncommented) <meta publisher:fit> but in
|
|
384
|
+
// <body> and omits </head>, so the head-only scan must stop at <body>
|
|
385
|
+
// and not opt in (locks the <body> boundary).
|
|
386
|
+
expect(
|
|
387
|
+
pages.find((p) => p.path === "bodymeta.html")?.fit,
|
|
388
|
+
).toBeUndefined();
|
|
389
|
+
|
|
390
|
+
// unterminated.html wraps the tag in a comment with no closing -->; the
|
|
391
|
+
// unterminated-comment strip must still keep it from opting in.
|
|
392
|
+
expect(
|
|
393
|
+
pages.find((p) => p.path === "unterminated.html")?.fit,
|
|
394
|
+
).toBeUndefined();
|
|
395
|
+
});
|
|
396
|
+
|
|
335
397
|
it("400s a malformed environment/package name on /pages", async () => {
|
|
336
398
|
// getEnvironment runs assertSafePackageName, so a name outside
|
|
337
399
|
// IdentifierPattern is a 400 (now documented on list-pages in api-doc).
|