diff --git a/infra/deploy-runner/build-module-foundry-map-sector-workspace-artifact.mjs b/infra/deploy-runner/build-module-foundry-map-sector-workspace-artifact.mjs new file mode 100644 index 0000000..bb168b2 --- /dev/null +++ b/infra/deploy-runner/build-module-foundry-map-sector-workspace-artifact.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = resolve(scriptDir, "../../.."); +const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE"); +const artifactDir = resolve( + process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"), +); +const [patchId = "module-foundry-map-sector-workspace-20260808-001", ...extra] = + process.argv.slice(2); + +if (extra.length || !/^module-foundry-map-sector-workspace-\d{8}-\d{3}$/.test(patchId)) { + throw new Error( + "usage: build-module-foundry-map-sector-workspace-artifact.mjs " + + "[module-foundry-map-sector-workspace-YYYYMMDD-NNN]", + ); +} + +const files = Object.freeze([ + "apps/catalog/src/MapFixturePreview.tsx", + "apps/catalog/src/mapPresentationProfile.ts", + "apps/catalog/src/mapSectorGrid.d.mts", + "apps/catalog/src/mapSectorGrid.mjs", + "apps/catalog/src/styles.css", + "scripts/map-object-layers.test.mjs", + "scripts/map-presentation-filters.test.mjs", + "scripts/map-sector-grid.test.mjs", +]); +const artifact = join(artifactDir, `nodedc-${patchId}.tgz`); +const checksum = `${artifact}.sha256`; +const stage = await mkdtemp(join(tmpdir(), "nodedc-foundry-map-sector-workspace-")); + +await assertFresh(artifact); +await assertMapSectorWorkspaceBoundary(); + +try { + for (const relativePath of files) { + const source = join(foundryRoot, relativePath); + const info = await lstat(source); + if (!info.isFile() || info.isSymbolicLink()) { + throw new Error(`source_file_rejected:${relativePath}`); + } + const destination = join(stage, "payload", relativePath); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination); + } + await writeFile( + join(stage, "manifest.env"), + `id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`, + "utf8", + ); + await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8"); + await mkdir(artifactDir, { recursive: true }); + run("python3", ["-c", canonicalTarScript(), artifact, stage]); + const sha256 = digest(await readFile(artifact)); + await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8"); + console.log(JSON.stringify({ + ok: true, + patchId, + artifact, + checksum, + sha256, + sourceCommit: gitHead(foundryRoot), + services: ["nodedc-module-foundry"], + transition: "activate-sector-scoped-map-workspace", + files, + }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +async function assertMapSectorWorkspaceBoundary() { + const preview = await readFile(join(foundryRoot, "apps/catalog/src/MapFixturePreview.tsx"), "utf8"); + const profile = await readFile(join(foundryRoot, "apps/catalog/src/mapPresentationProfile.ts"), "utf8"); + const sectorGrid = await readFile(join(foundryRoot, "apps/catalog/src/mapSectorGrid.mjs"), "utf8"); + const sectorGridTypes = await readFile(join(foundryRoot, "apps/catalog/src/mapSectorGrid.d.mts"), "utf8"); + const styles = await readFile(join(foundryRoot, "apps/catalog/src/styles.css"), "utf8"); + const objectTests = await readFile(join(foundryRoot, "scripts/map-object-layers.test.mjs"), "utf8"); + const filterTests = await readFile(join(foundryRoot, "scripts/map-presentation-filters.test.mjs"), "utf8"); + const sectorTests = await readFile(join(foundryRoot, "scripts/map-sector-grid.test.mjs"), "utf8"); + + for (const marker of [ + "type MapWorkspaceWindowId = \"settings\" | \"layers\" | \"sector\" | \"subject-card\"", + "const sectorSpatialEntities = useMemo", + "const sectorScopedPrimaryRuntimeBindings = useMemo", + "runtimeBindings={[...sectorScopedPrimaryRuntimeBindings, ...referenceRuntimeBindings]}", + "label=\"Скрыть объекты за сектором\"", + "onClose={deactivateGridSector}", + "Деактивировать сектор", + "Домены данных", + "Провайдеры", + "Типы объектов", + "Объекты сектора", + ]) { + if (!preview.includes(marker)) throw new Error(`sector_workspace_marker_missing:${marker}`); + } + for (const marker of [ + "Values inside one facet form a union", + "return selectedFacets.every", + ]) { + if (!profile.includes(marker)) throw new Error(`presentation_filter_marker_missing:${marker}`); + } + for (const marker of [ + "export function geodeticToLocalGridPlane", + "export function localSectorAtGeodetic", + "normalScale", + ]) { + if (!sectorGrid.includes(marker)) throw new Error(`sector_projection_marker_missing:${marker}`); + if (marker.startsWith("export function") && !sectorGridTypes.includes(marker)) { + throw new Error(`sector_projection_type_marker_missing:${marker}`); + } + } + for (const marker of [ + ".catalog-map-fixture__sector-window", + ".catalog-map-sector-window__object-list", + ]) { + if (!styles.includes(marker)) throw new Error(`sector_styles_marker_missing:${marker}`); + } + for (const marker of [ + "an active sector scopes point facts, exposes data facets and deactivates on close", + "map windows share one bounded activation and z-index stack", + ]) { + if (!objectTests.includes(marker)) throw new Error(`sector_object_regression_missing:${marker}`); + } + if (!filterTests.includes("chips are OR within one facet and AND between facets")) { + throw new Error("sector_filter_regression_missing"); + } + if (!sectorTests.includes("WGS84 positions use the same tangent-plane address as Cesium sector picking")) { + throw new Error("sector_projection_regression_missing"); + } +} + +async function assertFresh(path) { + try { + await lstat(path); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + throw new Error("artifact_already_exists"); +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'wb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} + +function digest(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function gitHead(path) { + const result = spawnSync("git", ["-C", path, "rev-parse", "HEAD"], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(`git_head_failed:${result.stderr || result.stdout}`); + return result.stdout.trim(); +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + if (result.status !== 0) { + throw new Error(`${command}_failed:${result.stderr || result.stdout}`); + } +} diff --git a/infra/deploy-runner/test_module_foundry_map_sector_workspace_artifact.py b/infra/deploy-runner/test_module_foundry_map_sector_workspace_artifact.py new file mode 100644 index 0000000..a637b0e --- /dev/null +++ b/infra/deploy-runner/test_module_foundry_map_sector_workspace_artifact.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 + +import hashlib +import importlib.machinery +import importlib.util +import json +import os +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +PLATFORM_ROOT = SCRIPT_DIR.parent.parent +BUILDER = SCRIPT_DIR / "build-module-foundry-map-sector-workspace-artifact.mjs" +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +PATCH_ID = "module-foundry-map-sector-workspace-20260808-999" +EXPECTED_FILES = ( + "apps/catalog/src/MapFixturePreview.tsx", + "apps/catalog/src/mapPresentationProfile.ts", + "apps/catalog/src/mapSectorGrid.d.mts", + "apps/catalog/src/mapSectorGrid.mjs", + "apps/catalog/src/styles.css", + "scripts/map-object-layers.test.mjs", + "scripts/map-presentation-filters.test.mjs", + "scripts/map-sector-grid.test.mjs", +) + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_module_foundry_map_sector_workspace_artifact", + str(RUNNER_PATH), + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + + +class ModuleFoundryMapSectorWorkspaceArtifactTest(unittest.TestCase): + def build(self, artifact_dir): + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + completed = subprocess.run( + ["node", str(BUILDER), PATCH_ID], + cwd=PLATFORM_ROOT, + env=environment, + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + def test_builder_is_deterministic_exact_scope_and_runner_compatible(self): + with tempfile.TemporaryDirectory(prefix="nodedc-foundry-map-sector-workspace-") as directory: + root = Path(directory) + first = self.build(root / "first") + second = self.build(root / "second") + first_artifact = Path(first["artifact"]) + second_artifact = Path(second["artifact"]) + + self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes()) + self.assertEqual(first["sha256"], hashlib.sha256(first_artifact.read_bytes()).hexdigest()) + self.assertEqual(tuple(first["files"]), EXPECTED_FILES) + self.assertEqual(first["services"], ["nodedc-module-foundry"]) + + extracted = root / "loaded" + extracted.mkdir() + manifest, entries, payload = RUNNER.load_artifact(first_artifact, extracted) + self.assertEqual(manifest["component"], "module-foundry") + self.assertEqual(tuple(entries), EXPECTED_FILES) + self.assertEqual(RUNNER.component_services("module-foundry", entries), ("nodedc-module-foundry",)) + + preview = (payload / EXPECTED_FILES[0]).read_text(encoding="utf-8") + profile = (payload / EXPECTED_FILES[1]).read_text(encoding="utf-8") + sector_grid = (payload / EXPECTED_FILES[3]).read_text(encoding="utf-8") + self.assertIn("const sectorSpatialEntities = useMemo", preview) + self.assertIn('label="Скрыть объекты за сектором"', preview) + self.assertIn("Деактивировать сектор", preview) + self.assertIn("return selectedFacets.every", profile) + self.assertIn("export function localSectorAtGeodetic", sector_grid) + + with tarfile.open(first_artifact, "r:gz") as archive: + members = archive.getmembers() + names = [member.name for member in members] + regular_payloads = [ + archive.extractfile(member).read() + for member in members + if member.isfile() + ] + self.assertFalse(any(Path(name).name.startswith("._") for name in names)) + self.assertFalse(any("/.git/" in name or "/node_modules/" in name or "/runtime-data/" in name for name in names)) + self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", b"\n".join(regular_payloads)) + + +if __name__ == "__main__": + unittest.main(verbosity=2)