@glossarist/concept-browser 0.7.116 → 0.7.118
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/package.json +6 -6
- package/scripts/build-scripts.mjs +11 -2
- package/scripts/generate-data.js +26 -4
- package/scripts/generate-data.ts +20 -0
- package/scripts/lib/vocab-turtle.ts +19 -4
- package/src/__tests__/consumer-e2e-simulation.test.ts +98 -0
- package/src/__tests__/published-package-contract.test.ts +28 -2
- package/scripts/smoke.js +0 -211
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.118",
|
|
4
4
|
"description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"@astrojs/sitemap": "^3.7.3",
|
|
37
37
|
"@astrojs/vue": "^7.0.1",
|
|
38
38
|
"@plurimath/plurimath": "^0.2.2",
|
|
39
|
+
"@rdfjs/dataset": "^2.0.2",
|
|
39
40
|
"@tailwindcss/vite": "^4.3.2",
|
|
40
41
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
41
42
|
"astro": "^7.0.7",
|
|
@@ -44,7 +45,9 @@
|
|
|
44
45
|
"glossarist": "^0.4.52",
|
|
45
46
|
"js-yaml": "^4.1.0",
|
|
46
47
|
"jszip": "^3.10.1",
|
|
48
|
+
"n3": "^1.26.0",
|
|
47
49
|
"pinia": "^2.3.1",
|
|
50
|
+
"rdf-validate-shacl": "^0.4.5",
|
|
48
51
|
"sharp": "^0.34.5",
|
|
49
52
|
"tailwindcss": "^4.3.2",
|
|
50
53
|
"tsx": "^4.23.4",
|
|
@@ -54,19 +57,16 @@
|
|
|
54
57
|
},
|
|
55
58
|
"devDependencies": {
|
|
56
59
|
"@astrojs/check": "^0.9.9",
|
|
57
|
-
"@rdfjs/dataset": "^2.0.2",
|
|
58
60
|
"@stryker-mutator/core": "^9.6.1",
|
|
59
61
|
"@stryker-mutator/vitest-runner": "^9.6.1",
|
|
60
62
|
"@types/d3": "^7.4.3",
|
|
61
63
|
"@types/n3": "^1.26.1",
|
|
62
|
-
"esbuild": "^0.28.1",
|
|
63
|
-
"glob": "^10.5.0",
|
|
64
64
|
"@vue/test-utils": "^2.4.8",
|
|
65
|
+
"esbuild": "^0.28.1",
|
|
65
66
|
"fast-check": "^4.8.0",
|
|
67
|
+
"glob": "^10.5.0",
|
|
66
68
|
"happy-dom": "^20.9.0",
|
|
67
|
-
"n3": "^1.26.0",
|
|
68
69
|
"playwright": "^1.62.1",
|
|
69
|
-
"rdf-validate-shacl": "^0.4.5",
|
|
70
70
|
"typescript": "~5.7.3",
|
|
71
71
|
"vitest": "^4.1.5",
|
|
72
72
|
"vue-tsc": "^2.2.8"
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { build } from 'esbuild';
|
|
2
2
|
import { globSync } from 'glob';
|
|
3
|
-
import {
|
|
3
|
+
import { relative } from 'path';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Exclude scripts that are dev/CI-only and depend on devDependencies.
|
|
6
|
+
// These are not consumer-facing CLI commands and would pull in heavy deps
|
|
7
|
+
// (e.g. playwright for smoke) that consumers shouldn't need.
|
|
8
|
+
const DEV_ONLY = new Set([
|
|
9
|
+
'scripts/smoke.ts', // needs playwright (devDep, heavy)
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const files = globSync('scripts/*.ts').filter(f =>
|
|
13
|
+
!f.includes('__tests__') && !DEV_ONLY.has(f),
|
|
14
|
+
);
|
|
6
15
|
|
|
7
16
|
await Promise.all(files.map(async (file) => {
|
|
8
17
|
const outfile = file.replace(/\.ts$/, '.js');
|
package/scripts/generate-data.js
CHANGED
|
@@ -420,7 +420,7 @@ async function buildActivityTurtle(input) {
|
|
|
420
420
|
}
|
|
421
421
|
|
|
422
422
|
// scripts/lib/vocab-turtle.ts
|
|
423
|
-
import { readFileSync as readFileSync2 } from "node:fs";
|
|
423
|
+
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
|
|
424
424
|
import { join, dirname as dirname2 } from "node:path";
|
|
425
425
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
426
426
|
import {
|
|
@@ -430,9 +430,21 @@ import {
|
|
|
430
430
|
PREFIXES as PREFIXES2
|
|
431
431
|
} from "glossarist/rdf";
|
|
432
432
|
var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
433
|
+
function findVocabPath() {
|
|
434
|
+
const candidates = [
|
|
435
|
+
join(__dirname2, "..", "..", "data", "glossarist-vocab.json"),
|
|
436
|
+
join(__dirname2, "..", "data", "glossarist-vocab.json")
|
|
437
|
+
];
|
|
438
|
+
for (const p of candidates) {
|
|
439
|
+
if (existsSync2(p)) return p;
|
|
440
|
+
}
|
|
441
|
+
throw new Error(
|
|
442
|
+
`glossarist-vocab.json not found. Tried:
|
|
443
|
+
${candidates.map((p) => " " + p).join("\n")}`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
function loadVocab() {
|
|
447
|
+
return JSON.parse(readFileSync2(findVocabPath(), "utf8"));
|
|
436
448
|
}
|
|
437
449
|
function writerPrefixes3() {
|
|
438
450
|
const out = {};
|
|
@@ -1090,6 +1102,9 @@ function buildPatternIndex(datasets, registerCache2) {
|
|
|
1090
1102
|
function buildRefMaps(config2, registerCache2) {
|
|
1091
1103
|
const refPrefixMap = {};
|
|
1092
1104
|
const patternIndex = buildPatternIndex(config2.datasets, registerCache2);
|
|
1105
|
+
for (const ds of config2.datasets) {
|
|
1106
|
+
if (ds.ref) refPrefixMap[ds.ref] = ds.id;
|
|
1107
|
+
}
|
|
1093
1108
|
for (const route of config2.routing || []) {
|
|
1094
1109
|
if (route.uri && route.uri.includes("iec") && route.uri.includes("60050")) {
|
|
1095
1110
|
const mapped = route.targetDataset;
|
|
@@ -1316,6 +1331,13 @@ function yamlToJsonLd(conceptYaml, register, refMaps2) {
|
|
|
1316
1331
|
if (r.ref.id) ref["gl:id"] = r.ref.id;
|
|
1317
1332
|
if (r.ref.text) ref["gl:text"] = r.ref.text;
|
|
1318
1333
|
rel["gl:ref"] = ref;
|
|
1334
|
+
if (r.ref.source && r.ref.id && refMaps2?.refPrefixMap?.[r.ref.source]) {
|
|
1335
|
+
const datasetId = refMaps2.refPrefixMap[r.ref.source];
|
|
1336
|
+
rel["gl:target"] = buildConceptUri(refMaps2.uriBase, datasetId, r.ref.id);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
if (r.target) {
|
|
1340
|
+
rel["gl:target"] = r.target;
|
|
1319
1341
|
}
|
|
1320
1342
|
return rel;
|
|
1321
1343
|
});
|
package/scripts/generate-data.ts
CHANGED
|
@@ -221,6 +221,14 @@ function buildRefMaps(config, registerCache) {
|
|
|
221
221
|
const refPrefixMap = {};
|
|
222
222
|
const patternIndex = buildPatternIndex(config.datasets, registerCache);
|
|
223
223
|
|
|
224
|
+
// Auto-map each dataset's `ref` field to its dataset ID.
|
|
225
|
+
// This enables cross-dataset link resolution: a concept in cie-2020
|
|
226
|
+
// with ref: { source: "CIE S 017:2011", id: "17-245" } resolves to
|
|
227
|
+
// cie-2011/concept/17-245 because cie-2011 has ref: "CIE S 017:2011".
|
|
228
|
+
for (const ds of config.datasets) {
|
|
229
|
+
if (ds.ref) refPrefixMap[ds.ref] = ds.id;
|
|
230
|
+
}
|
|
231
|
+
|
|
224
232
|
for (const route of config.routing || []) {
|
|
225
233
|
if (route.uri && route.uri.includes('iec') && route.uri.includes('60050')) {
|
|
226
234
|
const mapped = route.targetDataset;
|
|
@@ -508,6 +516,18 @@ function yamlToJsonLd(conceptYaml, register, refMaps) {
|
|
|
508
516
|
if (r.ref.id) ref['gl:id'] = r.ref.id;
|
|
509
517
|
if (r.ref.text) ref['gl:text'] = r.ref.text;
|
|
510
518
|
rel['gl:ref'] = ref;
|
|
519
|
+
|
|
520
|
+
// Resolve cross-dataset refs to gl:target URIs.
|
|
521
|
+
// refPrefixMap maps source labels (e.g. "CIE S 017:2011") to
|
|
522
|
+
// dataset IDs (e.g. "cie-2011"). When the source matches a
|
|
523
|
+
// co-deployed dataset, we can build a navigable concept URI.
|
|
524
|
+
if (r.ref.source && r.ref.id && refMaps?.refPrefixMap?.[r.ref.source]) {
|
|
525
|
+
const datasetId = refMaps.refPrefixMap[r.ref.source];
|
|
526
|
+
rel['gl:target'] = buildConceptUri(refMaps.uriBase, datasetId, r.ref.id);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (r.target) {
|
|
530
|
+
rel['gl:target'] = r.target;
|
|
511
531
|
}
|
|
512
532
|
return rel;
|
|
513
533
|
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// to absolute IRIs, then re-rendered via glossarist-js's n3 writer with
|
|
8
8
|
// the canonical prefix map ).
|
|
9
9
|
|
|
10
|
-
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
11
11
|
import { join, dirname } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import {
|
|
@@ -18,10 +18,25 @@ import {
|
|
|
18
18
|
} from 'glossarist/rdf';
|
|
19
19
|
|
|
20
20
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
-
const VOCAB_PATH = join(__dirname, '..', '..', 'data', 'glossarist-vocab.json');
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
// When running from source (tsx), this file is at scripts/lib/ and
|
|
23
|
+
// the data dir is two levels up. When bundled by esbuild into
|
|
24
|
+
// scripts/*.js, __dirname is scripts/ and the data dir is one level up.
|
|
25
|
+
function findVocabPath(): string {
|
|
26
|
+
const candidates = [
|
|
27
|
+
join(__dirname, '..', '..', 'data', 'glossarist-vocab.json'),
|
|
28
|
+
join(__dirname, '..', 'data', 'glossarist-vocab.json'),
|
|
29
|
+
];
|
|
30
|
+
for (const p of candidates) {
|
|
31
|
+
if (existsSync(p)) return p;
|
|
32
|
+
}
|
|
33
|
+
throw new Error(
|
|
34
|
+
`glossarist-vocab.json not found. Tried:\n${candidates.map(p => ' ' + p).join('\n')}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadVocab() {
|
|
39
|
+
return JSON.parse(readFileSync(findVocabPath(), 'utf8'));
|
|
25
40
|
}
|
|
26
41
|
|
|
27
42
|
// n3's writer prefixes must omit the trailing # or / — those go in
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end consumer simulation test.
|
|
3
|
+
*
|
|
4
|
+
* Simulates what a real consumer experiences: creates a temp "consumer
|
|
5
|
+
* project" with the compiled package in node_modules, then runs scripts
|
|
6
|
+
* with plain node. Catches packaging bugs that dev-mode tests miss.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import {
|
|
12
|
+
mkdirSync, writeFileSync, cpSync, rmSync, existsSync, readFileSync, readdirSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { resolve, join, dirname } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const ROOT = resolve(__dirname, '..', '..');
|
|
19
|
+
const SCRIPTS_DIR = join(ROOT, 'scripts');
|
|
20
|
+
const CONSUMER_DIR = join(ROOT, '.tmp-consumer-test');
|
|
21
|
+
|
|
22
|
+
describe('end-to-end consumer simulation — compiled package works under plain node', () => {
|
|
23
|
+
beforeAll(() => {
|
|
24
|
+
const build = spawnSync('node', [join(ROOT, 'scripts', 'build-scripts.mjs')], {
|
|
25
|
+
encoding: 'utf8', cwd: ROOT,
|
|
26
|
+
});
|
|
27
|
+
expect(build.status).toBe(0);
|
|
28
|
+
|
|
29
|
+
rmSync(CONSUMER_DIR, { recursive: true, force: true });
|
|
30
|
+
const pkgDest = join(CONSUMER_DIR, 'node_modules', '@glossarist', 'concept-browser');
|
|
31
|
+
mkdirSync(pkgDest, { recursive: true });
|
|
32
|
+
cpSync(join(ROOT, 'scripts'), join(pkgDest, 'scripts'), { recursive: true });
|
|
33
|
+
cpSync(join(ROOT, 'data'), join(pkgDest, 'data'), { recursive: true });
|
|
34
|
+
cpSync(join(ROOT, 'cli'), join(pkgDest, 'cli'), { recursive: true });
|
|
35
|
+
writeFileSync(join(pkgDest, 'package.json'), JSON.stringify({
|
|
36
|
+
name: '@glossarist/concept-browser', version: '0.0.0-test', type: 'module',
|
|
37
|
+
bin: { 'concept-browser': './cli/index.mjs' },
|
|
38
|
+
}));
|
|
39
|
+
}, 60000);
|
|
40
|
+
|
|
41
|
+
afterAll(() => {
|
|
42
|
+
rmSync(CONSUMER_DIR, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('compiled scripts can find package data files from node_modules location', () => {
|
|
46
|
+
const scriptDir = join(CONSUMER_DIR, 'node_modules', '@glossarist', 'concept-browser', 'scripts');
|
|
47
|
+
const pathFromBundled = join(scriptDir, '..', 'data', 'glossarist-vocab.json');
|
|
48
|
+
expect(existsSync(pathFromBundled)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('compiled scripts list matches source scripts (no missing outputs)', () => {
|
|
52
|
+
const sourceTs = readdirSync(SCRIPTS_DIR)
|
|
53
|
+
.filter(f => f.endsWith('.ts') && !f.includes('__tests__') && f !== 'smoke.ts')
|
|
54
|
+
.map(f => f.replace(/\.ts$/, ''));
|
|
55
|
+
const compiledJs = readdirSync(SCRIPTS_DIR)
|
|
56
|
+
.filter(f => f.endsWith('.js'))
|
|
57
|
+
.map(f => f.replace(/\.js$/, ''));
|
|
58
|
+
for (const name of sourceTs) {
|
|
59
|
+
expect(compiledJs).toContain(name);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('validate-yaml.js validates against the shipped schema from node_modules', () => {
|
|
64
|
+
const consumerPkg = join(CONSUMER_DIR, 'node_modules', '@glossarist', 'concept-browser');
|
|
65
|
+
const scriptPath = join(consumerPkg, 'scripts', 'validate-yaml.js');
|
|
66
|
+
const schemaPath = join(consumerPkg, 'data', 'concept-model', 'shapes', 'glossarist.concept.yaml.schema.json');
|
|
67
|
+
const code = [
|
|
68
|
+
'import { validateYaml } from ' + JSON.stringify(scriptPath) + ';',
|
|
69
|
+
'import { readFileSync } from "fs";',
|
|
70
|
+
'const schema = JSON.parse(readFileSync(' + JSON.stringify(schemaPath) + ', "utf8"));',
|
|
71
|
+
'const r = validateYaml("termid: test", schema);',
|
|
72
|
+
'console.log("valid=" + r.valid);',
|
|
73
|
+
].join('\n');
|
|
74
|
+
const result = spawnSync('node', ['--input-type=module', '-e', code], {
|
|
75
|
+
encoding: 'utf8', cwd: ROOT,
|
|
76
|
+
});
|
|
77
|
+
expect(result.status).toBe(0);
|
|
78
|
+
expect(result.stdout).toContain('valid=true');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('no compiled script references a devDependency-only package', () => {
|
|
82
|
+
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
|
83
|
+
const deps = new Set(Object.keys(pkg.dependencies || {}));
|
|
84
|
+
const devDeps = new Set(Object.keys(pkg.devDependencies || {}));
|
|
85
|
+
const violations: string[] = [];
|
|
86
|
+
for (const file of readdirSync(SCRIPTS_DIR).filter(f => f.endsWith('.js'))) {
|
|
87
|
+
const src = readFileSync(join(SCRIPTS_DIR, file), 'utf8');
|
|
88
|
+
const importRe = new RegExp('(?:from\\s+|import\\s*\\(\\s*)["\']([^"\'./@][^"\']*)["\']', 'g');
|
|
89
|
+
let m;
|
|
90
|
+
while ((m = importRe.exec(src)) !== null) {
|
|
91
|
+
const name = m[1].startsWith('@') ? m[1].split('/').slice(0, 2).join('/') : m[1].split('/')[0];
|
|
92
|
+
if (name.startsWith('node:') || name === 'node') continue;
|
|
93
|
+
if (devDeps.has(name) && !deps.has(name)) violations.push(file + ' -> ' + name);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
expect(violations).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { resolve, join, dirname } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
|
|
@@ -21,7 +21,6 @@ const PIPELINE_SCRIPTS = [
|
|
|
21
21
|
'generate-data',
|
|
22
22
|
'build-edges',
|
|
23
23
|
'process-about-pages',
|
|
24
|
-
'smoke',
|
|
25
24
|
'generate-ontology-data',
|
|
26
25
|
'generate-ontology-schema',
|
|
27
26
|
'doctor',
|
|
@@ -81,4 +80,31 @@ describe('published-package contract — compiled .js scripts load under plain n
|
|
|
81
80
|
expect(result.status).toBe(0);
|
|
82
81
|
expect(result.stdout).toContain('valid=true');
|
|
83
82
|
});
|
|
83
|
+
|
|
84
|
+
it('compiled scripts do not import devDependencies (runtime packaging regression)', () => {
|
|
85
|
+
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
|
86
|
+
const deps = new Set(Object.keys(pkg.dependencies || {}));
|
|
87
|
+
const devDeps = new Set(Object.keys(pkg.devDependencies || {}));
|
|
88
|
+
for (const name of LIBRARY_SCRIPTS) {
|
|
89
|
+
const jsPath = join(SCRIPTS_DIR, `${name}.js`);
|
|
90
|
+
if (!existsSync(jsPath)) continue;
|
|
91
|
+
const src = readFileSync(jsPath, 'utf8');
|
|
92
|
+
for (const devDep of devDeps) {
|
|
93
|
+
if (deps.has(devDep)) continue;
|
|
94
|
+
expect(src).not.toContain(`from "${devDep}"`);
|
|
95
|
+
expect(src).not.toContain(`from '${devDep}'`);
|
|
96
|
+
expect(src).not.toMatch(new RegExp(`import\\s*\\(\\s*["']${devDep}["']`));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('compiled scripts can find package data files (glossarist-vocab.json path regression)', () => {
|
|
102
|
+
// When esbuild bundles scripts/lib/vocab-turtle.ts into scripts/generate-data.js,
|
|
103
|
+
// __dirname changes from scripts/lib/ to scripts/. The path to data/ must
|
|
104
|
+
// resolve correctly in both modes. This test verifies the fallback logic.
|
|
105
|
+
const dataDir = join(ROOT, 'data');
|
|
106
|
+
expect(existsSync(join(dataDir, 'glossarist-vocab.json'))).toBe(true);
|
|
107
|
+
expect(existsSync(join(dataDir, 'colors.json'))).toBe(true);
|
|
108
|
+
expect(existsSync(join(dataDir, 'concept-model', 'shapes', 'glossarist.shacl.ttl'))).toBe(true);
|
|
109
|
+
});
|
|
84
110
|
});
|
package/scripts/smoke.js
DELETED
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// scripts/smoke.ts
|
|
4
|
-
import { chromium } from "playwright";
|
|
5
|
-
import { createServer } from "http";
|
|
6
|
-
import { statSync, createReadStream } from "fs";
|
|
7
|
-
import { extname, join, normalize, resolve as pathResolve } from "path";
|
|
8
|
-
import { fileURLToPath } from "url";
|
|
9
|
-
var __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
10
|
-
var DIST = pathResolve(__dirname, "..", "dist");
|
|
11
|
-
var MIME = {
|
|
12
|
-
".html": "text/html; charset=utf-8",
|
|
13
|
-
".js": "application/javascript; charset=utf-8",
|
|
14
|
-
"": "application/javascript; charset=utf-8",
|
|
15
|
-
".css": "text/css; charset=utf-8",
|
|
16
|
-
".json": "application/json; charset=utf-8",
|
|
17
|
-
".svg": "image/svg+xml",
|
|
18
|
-
".png": "image/png",
|
|
19
|
-
".jpg": "image/jpeg",
|
|
20
|
-
".ico": "image/x-icon",
|
|
21
|
-
".webp": "image/webp",
|
|
22
|
-
".woff": "font/woff",
|
|
23
|
-
".woff2": "font/woff2",
|
|
24
|
-
".ttf": "font/ttf",
|
|
25
|
-
".map": "application/json; charset=utf-8",
|
|
26
|
-
".xml": "application/xml; charset=utf-8",
|
|
27
|
-
".txt": "text/plain; charset=utf-8",
|
|
28
|
-
".webmanifest": "application/manifest+json; charset=utf-8"
|
|
29
|
-
};
|
|
30
|
-
function serveDist(root) {
|
|
31
|
-
return new Promise((resolve) => {
|
|
32
|
-
const server = createServer((req, res) => {
|
|
33
|
-
try {
|
|
34
|
-
const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
35
|
-
let filePath = join(root, urlPath);
|
|
36
|
-
if (!extname(filePath)) {
|
|
37
|
-
if (statSafe(filePath)?.isFile()) {
|
|
38
|
-
return sendFile(res, filePath);
|
|
39
|
-
}
|
|
40
|
-
const candidates = [
|
|
41
|
-
filePath + ".html",
|
|
42
|
-
join(filePath, "index.html"),
|
|
43
|
-
join(root, "200.html"),
|
|
44
|
-
join(root, "index.html")
|
|
45
|
-
];
|
|
46
|
-
for (const c of candidates) {
|
|
47
|
-
if (statSafe(c)?.isFile()) {
|
|
48
|
-
return sendFile(res, c);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
res.writeHead(404);
|
|
52
|
-
return res.end("Not found");
|
|
53
|
-
}
|
|
54
|
-
if (statSafe(filePath)?.isFile()) {
|
|
55
|
-
return sendFile(res, filePath);
|
|
56
|
-
}
|
|
57
|
-
res.writeHead(404);
|
|
58
|
-
return res.end("Not found");
|
|
59
|
-
} catch (e) {
|
|
60
|
-
res.writeHead(500);
|
|
61
|
-
res.end(String(e ?? "server error"));
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
server.listen(0, "127.0.0.1", () => {
|
|
65
|
-
const addr = server.address();
|
|
66
|
-
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
67
|
-
resolve({ server, origin: `http://127.0.0.1:${port}` });
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
function statSafe(p) {
|
|
72
|
-
try {
|
|
73
|
-
return statSync(p);
|
|
74
|
-
} catch {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
function sendFile(res, filePath) {
|
|
79
|
-
const safe = normalize(filePath);
|
|
80
|
-
const ct = MIME[extname(safe)] ?? "application/octet-stream";
|
|
81
|
-
res.writeHead(200, { "Content-Type": ct });
|
|
82
|
-
createReadStream(safe).pipe(res);
|
|
83
|
-
}
|
|
84
|
-
var PAGE_PATHS = [
|
|
85
|
-
"/",
|
|
86
|
-
"/learn/",
|
|
87
|
-
"/learn/relationships/",
|
|
88
|
-
"/learn/designations/",
|
|
89
|
-
"/learn/statuses/"
|
|
90
|
-
];
|
|
91
|
-
var CONSUMER_DATA_FILES = [
|
|
92
|
-
"/datasets.json",
|
|
93
|
-
"/site-config.json",
|
|
94
|
-
"/data/cross-ref-index.json"
|
|
95
|
-
];
|
|
96
|
-
function isConsumerDataFile(url) {
|
|
97
|
-
try {
|
|
98
|
-
const u = new URL(url);
|
|
99
|
-
return CONSUMER_DATA_FILES.some((f) => u.pathname === f || u.pathname.startsWith("/data/"));
|
|
100
|
-
} catch {
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
async function probePage(browser, origin, path) {
|
|
105
|
-
const url = new URL(path, origin).toString();
|
|
106
|
-
const page = await browser.newPage();
|
|
107
|
-
const errors = [];
|
|
108
|
-
const failedRequests = [];
|
|
109
|
-
page.on("pageerror", (e) => {
|
|
110
|
-
errors.push({ kind: "pageerror", message: e.message, stack: e.stack ?? "" });
|
|
111
|
-
});
|
|
112
|
-
page.on("console", (msg) => {
|
|
113
|
-
if (msg.type() !== "error") return;
|
|
114
|
-
const text = msg.text();
|
|
115
|
-
if (/Failed to load resource/i.test(text)) return;
|
|
116
|
-
if (/fonts\.g(oogle|static)\.com/.test(text)) return;
|
|
117
|
-
errors.push({ kind: "console.error", message: text });
|
|
118
|
-
});
|
|
119
|
-
page.on("requestfailed", (req) => {
|
|
120
|
-
const u = req.url();
|
|
121
|
-
if (u.startsWith(origin) && !isConsumerDataFile(u)) {
|
|
122
|
-
failedRequests.push({ url: u, failure: req.failure()?.errorText ?? "failed" });
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
page.on("response", (resp) => {
|
|
126
|
-
const u = resp.url();
|
|
127
|
-
if (u.startsWith(origin) && resp.status() >= 400 && !isConsumerDataFile(u)) {
|
|
128
|
-
failedRequests.push({ url: u, status: resp.status() });
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
try {
|
|
132
|
-
const resp = await page.goto(url, { waitUntil: "networkidle", timeout: 15e3 });
|
|
133
|
-
if (resp && resp.status() >= 400) {
|
|
134
|
-
return { path, url, status: resp.status(), bodyLen: 0, errors, failedRequests, skipped: path !== "/" };
|
|
135
|
-
}
|
|
136
|
-
await page.waitForTimeout(500);
|
|
137
|
-
const bodyLen = await page.evaluate(() => document.body?.innerText?.length ?? 0);
|
|
138
|
-
return { path, url, status: resp?.status() ?? 0, bodyLen, errors, failedRequests, skipped: false };
|
|
139
|
-
} catch (e) {
|
|
140
|
-
errors.push({ kind: "navigation", message: String(e) });
|
|
141
|
-
return { path, url, status: 0, bodyLen: 0, errors, failedRequests, skipped: false };
|
|
142
|
-
} finally {
|
|
143
|
-
await page.close();
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
function formatFindings(results) {
|
|
147
|
-
const lines = [];
|
|
148
|
-
for (const r of results) {
|
|
149
|
-
if (r.skipped) {
|
|
150
|
-
lines.push(`
|
|
151
|
-
${r.path} (HTTP ${r.status} \u2014 skipped, not in this build)`);
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
if (r.errors.length === 0 && r.failedRequests.length === 0 && r.bodyLen > 0) continue;
|
|
155
|
-
lines.push(`
|
|
156
|
-
${r.path} (HTTP ${r.status}, body ${r.bodyLen} chars)`);
|
|
157
|
-
for (const e of r.errors) {
|
|
158
|
-
lines.push(` [${e.kind}] ${e.message}`);
|
|
159
|
-
}
|
|
160
|
-
for (const f of r.failedRequests) {
|
|
161
|
-
lines.push(` [request] ${f.status ?? f.failure} ${f.url}`);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return lines.join("\n");
|
|
165
|
-
}
|
|
166
|
-
async function ensureDist() {
|
|
167
|
-
if (!statSafe(DIST)?.isDirectory()) {
|
|
168
|
-
console.error(`smoke: dist/ not found at ${DIST}`);
|
|
169
|
-
console.error(` run \`npm run build\` first, or use \`npm run build:full\`.`);
|
|
170
|
-
process.exit(2);
|
|
171
|
-
}
|
|
172
|
-
if (!statSafe(join(DIST, "index.html"))?.isFile()) {
|
|
173
|
-
console.error(`smoke: dist/index.html missing \u2014 build did not complete.`);
|
|
174
|
-
process.exit(2);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
async function main() {
|
|
178
|
-
await ensureDist();
|
|
179
|
-
const { server, origin } = await serveDist(DIST);
|
|
180
|
-
let exitCode = 0;
|
|
181
|
-
const browser = await chromium.launch({ headless: true });
|
|
182
|
-
try {
|
|
183
|
-
const results = [];
|
|
184
|
-
for (const path of PAGE_PATHS) {
|
|
185
|
-
const r = await probePage(browser, origin, path);
|
|
186
|
-
results.push(r);
|
|
187
|
-
}
|
|
188
|
-
const anyFail = results.some(
|
|
189
|
-
(r) => !r.skipped && (r.errors.length > 0 || r.failedRequests.length > 0 || r.bodyLen === 0)
|
|
190
|
-
);
|
|
191
|
-
const findings = formatFindings(results);
|
|
192
|
-
if (anyFail) {
|
|
193
|
-
exitCode = 1;
|
|
194
|
-
console.error(`
|
|
195
|
-
FAIL smoke test found problems:${findings}
|
|
196
|
-
`);
|
|
197
|
-
} else {
|
|
198
|
-
const skipped = results.filter((r) => r.skipped).length;
|
|
199
|
-
console.log(`
|
|
200
|
-
OK smoke test passed (${results.length - skipped} pages, ${skipped} skipped, 0 errors).`);
|
|
201
|
-
}
|
|
202
|
-
} finally {
|
|
203
|
-
await browser.close();
|
|
204
|
-
server.close();
|
|
205
|
-
}
|
|
206
|
-
process.exit(exitCode);
|
|
207
|
-
}
|
|
208
|
-
main().catch((e) => {
|
|
209
|
-
console.error("smoke: fatal error", e);
|
|
210
|
-
process.exit(1);
|
|
211
|
-
});
|