Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e150dd61a
|
||
|
|
92b78bc996
|
||
|
|
a3819b469c
|
||
|
|
09fdb0663c
|
||
|
|
39e4c3eee7
|
||
|
|
80e859534e | ||
|
|
eee42e951d
|
||
|
|
177db4b06b
|
||
|
|
57670e9891
|
||
|
|
220cd529f1
|
||
|
|
fde9cd8183
|
||
|
|
920dd57035
|
||
|
|
54c02db3a0
|
||
|
|
a4ab8a4fa1
|
||
|
|
5005f3c9e2
|
||
|
|
ac835f8b93
|
||
|
|
afc0c145ac
|
||
|
|
a80b619636
|
||
|
|
d652fba131
|
||
|
|
2a4c66ba10
|
||
|
|
d82a035704
|
||
|
|
f78158f63b
|
||
|
|
fb6cc728ac
|
||
|
|
205b36ff54
|
||
|
|
7cfae8baab
|
||
|
|
42c0d249f8
|
||
|
|
2760592af0
|
||
|
|
8ccbcc7b1e
|
||
|
|
fa13e80e5b
|
||
|
|
09b7b300b6
|
||
|
|
7ed4989114
|
||
|
|
8b91950aca
|
||
|
|
9d8c8cec41 | ||
|
|
1916324e6d
|
||
|
|
a26c18c90c |
No files matched your search
@@ -0,0 +1,29 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
node:
|
||||
# @main until the owner moves ci-workflows v1 onto the publishing node.yaml; then back to @v1.
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
|
||||
with:
|
||||
node-version: '24'
|
||||
install: npm ci --ignore-scripts
|
||||
uv: true
|
||||
commands: |
|
||||
uv sync --locked
|
||||
uv run --locked --no-sync python .github/scripts/test-script-quality.py
|
||||
uv run --locked --no-sync python .github/scripts/check-script-quality.py
|
||||
uv run --locked --no-sync python .github/scripts/test-source-headers.py
|
||||
uv run --locked --no-sync python .github/scripts/check-source-headers.py
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run check
|
||||
npm run bundle
|
||||
# A v<package.json version> tag creates the Gitea release with the bundle, SHA256SUMS and the
|
||||
# CHANGELOG section (-rc.N -> prerelease). The package is private: nothing goes to npm.
|
||||
publish: true
|
||||
npm-publish-dirs: ''
|
||||
release-files: dist/*
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"typeCheckingMode": "strict",
|
||||
"pythonVersion": "3.13",
|
||||
"include": [
|
||||
"scripts/*.py"
|
||||
],
|
||||
"reportMissingTypeStubs": "error",
|
||||
"reportUnnecessaryTypeIgnoreComment": "error",
|
||||
"reportImplicitOverride": "error",
|
||||
"exclude": [
|
||||
"**/__pycache__"
|
||||
],
|
||||
"venvPath": "..",
|
||||
"venv": ".venv"
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Reject missing/malformed CodeQL evidence before enforcing the severity gate."""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def check(directory, language, threshold):
|
||||
if not math.isfinite(threshold) or not 0 <= threshold <= 10:
|
||||
raise ValueError("Invalid security severity threshold")
|
||||
canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language)
|
||||
files = sorted(Path(directory).glob("*.sarif"))
|
||||
if not canonical or not any(path.name == canonical + ".sarif" for path in files):
|
||||
raise ValueError("Missing expected CodeQL SARIF report for " + language)
|
||||
blocking = []
|
||||
for path in files:
|
||||
report = json.loads(path.read_text(encoding="utf-8"))
|
||||
runs = report.get("runs")
|
||||
if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs:
|
||||
raise ValueError("Invalid or empty SARIF runs: " + path.name)
|
||||
for run in runs:
|
||||
driver = run.get("tool", {}).get("driver", {})
|
||||
results = run.get("results")
|
||||
if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list):
|
||||
raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name)
|
||||
rules = {}
|
||||
for component in [driver, *run.get("tool", {}).get("extensions", [])]:
|
||||
for rule in component.get("rules", []):
|
||||
rules[rule["id"]] = rule
|
||||
if not rules:
|
||||
raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name)
|
||||
for invocation in run.get("invocations", []):
|
||||
if invocation.get("executionSuccessful") is False:
|
||||
raise ValueError("CodeQL invocation failed: " + path.name)
|
||||
for result in results:
|
||||
rule_id = result.get("ruleId") or result.get("rule", {}).get("id")
|
||||
if rule_id not in rules:
|
||||
raise ValueError("Result references missing rule metadata: " + path.name)
|
||||
if result.get("suppressions"):
|
||||
continue
|
||||
severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0)
|
||||
if not math.isfinite(severity) or not 0 <= severity <= 10:
|
||||
raise ValueError("Invalid rule severity: " + rule_id)
|
||||
if severity >= threshold:
|
||||
blocking.append((severity, rule_id))
|
||||
for severity, rule_id in sorted(blocking, reverse=True):
|
||||
print(f"::error title=CodeQL security finding::{rule_id} ({severity})")
|
||||
print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)")
|
||||
return bool(blocking)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0)
|
||||
@@ -0,0 +1,137 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Fail closed unless every tracked owned script is covered by, and really analyzed by, the quality tools."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
PYTHON_SUFFIXES = (".py", ".pyi", ".pyw")
|
||||
SCRIPT_PATTERN = ".github/scripts/*.py"
|
||||
PYRIGHT_CONFIG = ".github"
|
||||
JAVASCRIPT_SUFFIXES = (".js", ".cjs", ".mjs", ".jsx", ".ts", ".mts", ".cts", ".tsx")
|
||||
# EX-0015: the only npm package with a license exception; this guard pins the reviewed copy.
|
||||
URI_JS_VERSION = "4.4.1"
|
||||
URI_JS_INTEGRITY = "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="
|
||||
URI_JS_LICENSE_SHA256 = "0af366eff4c01ec147c9c61ea9e8ffad64a4294754c9d79355f3fd1b97cb2fb9"
|
||||
# Microsoft's official npm Pyright, restored from package-lock.json by `npm ci --ignore-scripts`.
|
||||
PYRIGHT_ENTRY = Path(__file__).resolve().parents[2] / "node_modules" / "pyright" / "index.js"
|
||||
|
||||
|
||||
def tracked_files(root: Path) -> list[str]:
|
||||
"""List tracked and not-yet-ignored new paths so no source file can silently bypass the gates."""
|
||||
# Fixed Git inventory operation; the root is an argument, never shell input.
|
||||
output = subprocess.run(["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"], check=True, capture_output=True).stdout.decode("utf-8") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command.
|
||||
return [name for name in output.split("\u0000") if name]
|
||||
|
||||
|
||||
def python_scope(tracked: list[str]) -> list[str]:
|
||||
"""Return the tracked Python sources, rejecting any outside the one checked script root."""
|
||||
candidates = sorted(name for name in tracked if name.endswith(PYTHON_SUFFIXES))
|
||||
outside = [name for name in candidates if not PurePosixPath(name).full_match(SCRIPT_PATTERN)]
|
||||
if outside:
|
||||
raise ValueError("Tracked Python outside the checked script root: " + ", ".join(outside))
|
||||
if not candidates:
|
||||
raise ValueError("No owned Python scripts selected")
|
||||
return candidates
|
||||
|
||||
|
||||
def javascript_scope(root: Path, tracked: list[str]) -> list[str]:
|
||||
"""Return tracked JavaScript/TypeScript sources, rejecting any outside the tsconfig include list."""
|
||||
candidates = sorted(name for name in tracked if name.endswith(JAVASCRIPT_SUFFIXES))
|
||||
tsconfig = root / "tsconfig.json"
|
||||
if not tsconfig.is_file():
|
||||
if candidates:
|
||||
raise ValueError("Tracked JavaScript/TypeScript without a tsconfig.json: " + ", ".join(candidates))
|
||||
return []
|
||||
includes: list[str] = json.loads(tsconfig.read_text(encoding="utf-8"))["include"]
|
||||
outside = [name for name in candidates if not any(PurePosixPath(name).full_match(pattern) for pattern in includes)]
|
||||
if outside:
|
||||
raise ValueError("Tracked JavaScript/TypeScript outside the tsconfig include list: " + ", ".join(outside))
|
||||
if not candidates:
|
||||
raise ValueError("No owned JavaScript sources selected")
|
||||
return candidates
|
||||
|
||||
|
||||
def run_pyright(root: Path, config: str) -> dict[str, Any]:
|
||||
"""Run the locked npm Pyright through Node with JSON output and return its full report."""
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
raise ValueError("Node.js is required to run the locked Pyright")
|
||||
if not PYRIGHT_ENTRY.is_file():
|
||||
raise ValueError(f"Locked Pyright is missing; run npm ci --ignore-scripts ({PYRIGHT_ENTRY})")
|
||||
# Resolved Node and locked entry script; the config is an argument, never shell input.
|
||||
completed = subprocess.run([node, str(PYRIGHT_ENTRY), "-p", config, "--outputjson"], cwd=root, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Resolved Node and locked entry script; no shell.
|
||||
try:
|
||||
report: dict[str, Any] = json.loads(completed.stdout[completed.stdout.index("{") :])
|
||||
int(report["summary"]["filesAnalyzed"]) # Fail here when the summary shape is missing.
|
||||
report["exitCode"] = completed.returncode
|
||||
except (ValueError, KeyError) as error:
|
||||
raise ValueError(f"Pyright produced no JSON summary (exit {completed.returncode}): {completed.stdout}{completed.stderr}") from error
|
||||
return report
|
||||
|
||||
|
||||
def check_pyright_report(report: dict[str, Any], expected: int) -> None:
|
||||
"""Require the analyzed-file count to match the selected sources, with no errors or warnings."""
|
||||
summary = report["summary"]
|
||||
analyzed = summary["filesAnalyzed"]
|
||||
if analyzed == 0 or analyzed != expected:
|
||||
raise ValueError(f"Pyright analyzed {analyzed} files, expected {expected}")
|
||||
if summary["errorCount"] or summary["warningCount"]:
|
||||
details = [f"{item['file']}: {item['message']}" for item in report["generalDiagnostics"]]
|
||||
raise ValueError("Pyright reported findings:\n" + "\n".join(details))
|
||||
if report["exitCode"] != 0:
|
||||
raise ValueError(f"Pyright exited {report['exitCode']} despite a clean summary")
|
||||
|
||||
|
||||
def run_ruff(root: Path, arguments: list[str], sources: list[str]) -> None:
|
||||
"""Run Ruff on the explicit source list so no selected file can be skipped."""
|
||||
# Fixed interpreter module invocation; paths are arguments, never shell input.
|
||||
if subprocess.run([sys.executable, "-m", "ruff", *arguments, "--no-cache", *sources], cwd=root, check=False).returncode != 0: # noqa: S603 -- Fixed interpreter and module; no shell.
|
||||
raise ValueError("Ruff " + " ".join(arguments) + " failed")
|
||||
|
||||
|
||||
def check_uri_js_exception(root: Path) -> None:
|
||||
"""Allow the EX-0015 license exception only for the reviewed, unmodified, development-only uri-js."""
|
||||
packages: dict[str, dict[str, Any]] = json.loads((root / "package-lock.json").read_text(encoding="utf-8"))["packages"]
|
||||
copies = [entry for path, entry in packages.items() if path.rsplit("node_modules/", 1)[-1] == "uri-js"]
|
||||
if len(copies) != 1:
|
||||
raise ValueError(f"EX-0015 covers exactly one locked uri-js copy, found {len(copies)}")
|
||||
if copies[0].get("version") != URI_JS_VERSION or copies[0].get("integrity") != URI_JS_INTEGRITY or copies[0].get("dev") is not True:
|
||||
raise ValueError(f"EX-0015 covers only development-only uri-js {URI_JS_VERSION} with the reviewed integrity")
|
||||
root_entry = packages.get("", {})
|
||||
if "uri-js" in {*root_entry.get("dependencies", {}), *root_entry.get("devDependencies", {})}:
|
||||
raise ValueError("EX-0015 covers uri-js only as a transitive tool dependency, not a direct one")
|
||||
consumers = [path for path, entry in packages.items() if "uri-js" in entry.get("dependencies", {}) and entry.get("dev") is not True]
|
||||
if consumers:
|
||||
raise ValueError("uri-js is required by a non-development package: " + ", ".join(consumers))
|
||||
digest = hashlib.sha256((root / "node_modules" / "uri-js" / "LICENSE").read_bytes()).hexdigest()
|
||||
if digest != URI_JS_LICENSE_SHA256:
|
||||
raise ValueError(f"Installed uri-js LICENSE bytes differ from the reviewed EX-0015 copy ({digest})")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Verify scope, then Ruff format/lint and strict Pyright with a positive analyzed count."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
tracked = tracked_files(root)
|
||||
sources = python_scope(tracked)
|
||||
javascript = javascript_scope(root, tracked)
|
||||
run_ruff(root, ["format", "--check"], sources)
|
||||
run_ruff(root, ["check"], sources)
|
||||
report = run_pyright(root, PYRIGHT_CONFIG)
|
||||
check_pyright_report(report, len(sources))
|
||||
check_uri_js_exception(root)
|
||||
print(f"Script quality verified: {len(sources)} Python scripts analyzed by Ruff and Pyright, {len(javascript)} JavaScript files in tsconfig scope")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -19,14 +19,14 @@ def selected(name: str, policy: dict[str, Any]) -> bool:
|
||||
return any(fnmatch.fnmatchcase(name, pattern) for pattern in policy["includes"])
|
||||
|
||||
|
||||
def leading_comments(text: str, style: str) -> str:
|
||||
def leading_comments(text: str, style: str) -> str: # noqa: PLR0912 -- Each recognized leading-comment syntax has a distinct fail-closed branch.
|
||||
"""Read comments only, preserving required interpreter/XML directive ordering."""
|
||||
text = text.removeprefix("\ufeff")
|
||||
if text.startswith("<?xml"):
|
||||
declaration = re.match(r"<\?xml[^?]*\?>", text)
|
||||
if style != "xml" or declaration is None:
|
||||
raise ValueError("Invalid XML declaration")
|
||||
text = text[declaration.end():]
|
||||
text = text[declaration.end() :]
|
||||
elif text.startswith("#!"):
|
||||
if style not in ("hash", "powershell", "slash"):
|
||||
raise ValueError("Unexpected interpreter directive")
|
||||
@@ -35,7 +35,7 @@ def leading_comments(text: str, style: str) -> str:
|
||||
if style != "powershell":
|
||||
raise ValueError("Unexpected PowerShell directive")
|
||||
text = text.partition("\n")[2]
|
||||
comments = []
|
||||
comments: list[str] = []
|
||||
while text.strip():
|
||||
text = text.lstrip()
|
||||
if text.startswith("<?xml") or re.match(r"#!|#Requires\b", text, re.IGNORECASE):
|
||||
@@ -46,11 +46,9 @@ def leading_comments(text: str, style: str) -> str:
|
||||
elif style == "slash" and text.startswith("//"):
|
||||
line, _, text = text.partition("\n")
|
||||
comments.append(line[2:])
|
||||
elif ((style == "slash" and text.startswith("/*"))
|
||||
or (style == "xml" and text.startswith("<!--"))
|
||||
or (style == "powershell" and text.startswith("<#"))):
|
||||
elif (style == "slash" and text.startswith("/*")) or (style == "xml" and text.startswith("<!--")) or (style == "powershell" and text.startswith("<#")):
|
||||
opening, closing = {"slash": ("/*", "*/"), "xml": ("<!--", "-->"), "powershell": ("<#", "#>")}[style]
|
||||
body, separator, rest = text[len(opening):].partition(closing)
|
||||
body, separator, rest = text[len(opening) :].partition(closing)
|
||||
if not separator:
|
||||
raise ValueError("Unterminated leading comment")
|
||||
comments.append(body)
|
||||
@@ -72,19 +70,22 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
|
||||
approved = [item for item in policy["notices"] if any(fnmatch.fnmatchcase(name, pattern) for pattern in item["paths"])]
|
||||
for notice in approved:
|
||||
holder = re.escape(notice["holder"])
|
||||
copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-–]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
|
||||
copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-\u2013]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
|
||||
if re.search(copyright_pattern, comments) is None:
|
||||
continue
|
||||
spdx = re.findall(r"(?m)^SPDX-License-Identifier:\s*(\S+)\s*$", comments)
|
||||
if spdx and any(value != notice["license"] for value in spdx):
|
||||
continue
|
||||
apache = all(phrase in comments for phrase in (
|
||||
"Licensed under the Apache License, Version 2.0",
|
||||
"you may not use this file except in compliance with the License.",
|
||||
"https://www.apache.org/licenses/LICENSE-2.0",
|
||||
"WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND",
|
||||
"limitations under the License.",
|
||||
))
|
||||
apache = all(
|
||||
phrase in comments
|
||||
for phrase in (
|
||||
"Licensed under the Apache License, Version 2.0",
|
||||
"you may not use this file except in compliance with the License.",
|
||||
"https://www.apache.org/licenses/LICENSE-2.0",
|
||||
"WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND",
|
||||
"limitations under the License.",
|
||||
)
|
||||
)
|
||||
if suffix == ".java" and not apache:
|
||||
continue
|
||||
if notice["license"] in spdx or (notice["license"] == "Apache-2.0" and apache):
|
||||
@@ -94,8 +95,9 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
|
||||
|
||||
def check_repository(root: Path, policy: dict[str, Any]) -> int:
|
||||
"""Check tracked sources without scanning build outputs or executing source."""
|
||||
tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000")
|
||||
failures = []
|
||||
# Fixed Git inventory operation; paths are arguments, never shell input.
|
||||
tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command.
|
||||
failures: list[str] = []
|
||||
count = 0
|
||||
for name in tracked:
|
||||
if not name or not selected(name, policy):
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Exercise the exact CI guards using synthetic evidence only."""
|
||||
import contextlib
|
||||
import copy
|
||||
import io
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
|
||||
def load(name):
|
||||
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
codeql = load("check-codeql-sarif")
|
||||
|
||||
|
||||
class SarifTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.directory = Path(self.temporary.name)
|
||||
self.report = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]}
|
||||
|
||||
def save(self, report):
|
||||
(self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8")
|
||||
|
||||
def test_clean_report_with_zero_findings_passes(self):
|
||||
self.save(self.report)
|
||||
self.assertFalse(codeql.check(self.directory, "java-kotlin", 7))
|
||||
|
||||
def test_missing_report_or_wrong_language_fails(self):
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "csharp", 7)
|
||||
|
||||
def test_empty_or_malformed_report_fails(self):
|
||||
for text in ["", "{", "null", "[]"]:
|
||||
with self.subTest(text=text):
|
||||
(self.directory / "java.sarif").write_text(text)
|
||||
with self.assertRaises((ValueError, AttributeError)):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
|
||||
def test_missing_runs_tool_rules_or_results_fails(self):
|
||||
variants = [{"version": "2.1.0"}, {"version": "2.1.0", "runs": []}]
|
||||
for field in ["tool", "results"]:
|
||||
bad = copy.deepcopy(self.report)
|
||||
del bad["runs"][0][field]
|
||||
variants.append(bad)
|
||||
bad = copy.deepcopy(self.report)
|
||||
bad["runs"][0]["tool"]["driver"]["rules"] = []
|
||||
variants.append(bad)
|
||||
for bad in variants:
|
||||
with self.subTest(report=bad):
|
||||
self.save(bad)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
|
||||
def test_high_finding_blocks_and_unknown_rule_fails(self):
|
||||
self.report["runs"][0]["results"] = [{"ruleId": "synthetic"}]
|
||||
self.save(self.report)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertTrue(codeql.check(self.directory, "java-kotlin", 7))
|
||||
self.report["runs"][0]["results"] = [{"ruleId": "missing"}]
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
|
||||
def test_failed_invocation_and_invalid_threshold_fail(self):
|
||||
self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}]
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", float("nan"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,281 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Prove the quality gates reject real violations, using private temporary fixtures and the locked tools."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, override
|
||||
from unittest import mock
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
GOOD_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
|
||||
UNUSED_IMPORT_PYTHON = '"""Probe module."""\n\nimport os\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
|
||||
TWO_SPACE_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
|
||||
INCOMPATIBLE_PYTHON = '"""Probe module."""\n\nVALUE: int = "text"\n'
|
||||
UNANNOTATED_PYTHON = '"""Probe module."""\n\n\ndef echo(value):\n """Return the value."""\n return value\n'
|
||||
GOOD_JAVASCRIPT = "export function addNumbers(left, right)\n{\n return left + right;\n}\n"
|
||||
BAD_LAYOUT_JAVASCRIPT = "export function addNumbers(left, right) {\n return left + right\n}\n"
|
||||
GOOD_TYPED_JAVASCRIPT = "/** @type {number} */\nexport const value = 1;\n"
|
||||
INCOMPATIBLE_JAVASCRIPT = '/** @type {number} */\nexport const value = "text";\n'
|
||||
|
||||
|
||||
def load(name: str) -> ModuleType:
|
||||
"""Load the real sibling guard for tests."""
|
||||
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
guard = load("check-script-quality")
|
||||
|
||||
|
||||
def run(command: list[str], cwd: Path, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
|
||||
"""Run one real tool invocation without a shell and capture its diagnostics."""
|
||||
return subprocess.run(command, cwd=cwd, input=stdin, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Fixed tool paths and private temporary arguments; no shell.
|
||||
|
||||
|
||||
def ruff(arguments: list[str], source: Path) -> subprocess.CompletedProcess[str]:
|
||||
"""Run the locked Ruff with the repository configuration on one private fixture."""
|
||||
return run([sys.executable, "-m", "ruff", *arguments, "--no-cache", "--config", str(ROOT / "ruff.toml"), str(source)], source.parent)
|
||||
|
||||
|
||||
class ScopeTests(unittest.TestCase):
|
||||
"""Reject tracked sources that fall outside the explicit tool roots."""
|
||||
|
||||
def test_python_outside_script_root_is_rejected(self) -> None:
|
||||
"""Reject a tracked Python file outside the script root instead of letting it disappear."""
|
||||
for stray in ("tools/helper.py", ".github/scripts/nested/helper.py", "helper.pyi"):
|
||||
with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the checked script root"):
|
||||
guard.python_scope([".github/scripts/check-a.py", stray])
|
||||
|
||||
def test_python_scope_returns_sorted_script_root_and_rejects_empty(self) -> None:
|
||||
"""Select only the direct script root, sorted, and reject selecting nothing."""
|
||||
self.assertEqual([".github/scripts/a.py", ".github/scripts/b.py"], guard.python_scope([".github/scripts/b.py", "README.md", ".github/scripts/a.py"]))
|
||||
with self.assertRaisesRegex(ValueError, "No owned Python"):
|
||||
guard.python_scope(["README.md"])
|
||||
|
||||
def test_javascript_requires_tsconfig_include_scope(self) -> None:
|
||||
"""Reject tracked JavaScript outside tsconfig include patterns, including .js/.cjs/.ts."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
(root / "tsconfig.json").write_text(json.dumps({"include": ["tools/**/*.mjs", "eslint.config.mjs"]}), encoding="utf-8")
|
||||
self.assertEqual(["eslint.config.mjs", "tools/a/b.mjs", "tools/c.mjs"], guard.javascript_scope(root, ["tools/c.mjs", "tools/a/b.mjs", "eslint.config.mjs", "README.md"]))
|
||||
for stray in ("src/z.js", "tools/w.cjs", "tools/v.ts", "tools/u.js"):
|
||||
with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the tsconfig include list"):
|
||||
guard.javascript_scope(root, ["tools/c.mjs", stray])
|
||||
with self.assertRaisesRegex(ValueError, "No owned JavaScript"):
|
||||
guard.javascript_scope(root, ["README.md"])
|
||||
|
||||
def test_javascript_without_tsconfig_is_rejected(self) -> None:
|
||||
"""Reject JavaScript in a repository that has no tsconfig."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
self.assertEqual([], guard.javascript_scope(root, ["README.md"]))
|
||||
with self.assertRaisesRegex(ValueError, "without a tsconfig.json"):
|
||||
guard.javascript_scope(root, ["tools/a.mjs"])
|
||||
|
||||
|
||||
class PyrightCountTests(unittest.TestCase):
|
||||
"""Reject Pyright reports that analyzed too few files or found anything."""
|
||||
|
||||
@staticmethod
|
||||
def report(analyzed: int, errors: int = 0, warnings: int = 0) -> dict[str, Any]:
|
||||
"""Build the exact Pyright JSON shape consumed by the guard."""
|
||||
return {"summary": {"filesAnalyzed": analyzed, "errorCount": errors, "warningCount": warnings}, "generalDiagnostics": [{"file": "x.py", "message": "finding"}] * (errors + warnings), "exitCode": 1 if errors or warnings else 0}
|
||||
|
||||
def test_nonzero_exit_fails_even_with_clean_summary(self) -> None:
|
||||
"""Fail a nonzero Pyright exit even when every count is clean."""
|
||||
clean = self.report(4)
|
||||
clean["exitCode"] = 1
|
||||
with self.assertRaisesRegex(ValueError, "exited 1"):
|
||||
guard.check_pyright_report(clean, 4)
|
||||
|
||||
def test_count_must_match_selected_sources(self) -> None:
|
||||
"""Fail zero or mismatched analyzed counts even with no diagnostics."""
|
||||
guard.check_pyright_report(self.report(4), 4)
|
||||
for analyzed in (0, 3, 5):
|
||||
with self.subTest(analyzed=analyzed), self.assertRaisesRegex(ValueError, "analyzed"):
|
||||
guard.check_pyright_report(self.report(analyzed), 4)
|
||||
|
||||
def test_errors_and_warnings_fail(self) -> None:
|
||||
"""Fail on errors and on warnings at the exact count."""
|
||||
for report in (self.report(4, errors=1), self.report(4, warnings=1)):
|
||||
with self.subTest(report=report), self.assertRaisesRegex(ValueError, "reported findings"):
|
||||
guard.check_pyright_report(report, 4)
|
||||
|
||||
|
||||
class RuffProbeTests(unittest.TestCase):
|
||||
"""Run the real locked Ruff on bounded private fixtures."""
|
||||
|
||||
def test_compliant_fixture_passes_and_violations_fail(self) -> None:
|
||||
"""Pass a compliant control and reject unused imports and wrong indentation."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
probe = Path(name) / "probe.py"
|
||||
probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
|
||||
self.assertEqual(0, ruff(["check"], probe).returncode)
|
||||
self.assertEqual(0, ruff(["format", "--check"], probe).returncode)
|
||||
probe.write_text(UNUSED_IMPORT_PYTHON, encoding="utf-8", newline="\n")
|
||||
unused = ruff(["check", "--output-format", "concise"], probe)
|
||||
self.assertNotEqual(0, unused.returncode)
|
||||
self.assertIn("F401", unused.stdout)
|
||||
probe.write_text(TWO_SPACE_PYTHON, encoding="utf-8", newline="\n")
|
||||
indented = ruff(["format", "--check"], probe)
|
||||
self.assertNotEqual(0, indented.returncode)
|
||||
self.assertIn("would be reformatted", (indented.stdout + indented.stderr).lower())
|
||||
|
||||
|
||||
class PyrightProbeTests(unittest.TestCase):
|
||||
"""Run the real locked Pyright with the repository strict settings on private projects."""
|
||||
|
||||
@staticmethod
|
||||
def project(root: Path, include: list[str] | None = None) -> None:
|
||||
"""Write the repository Pyright config, without its venv location, into a private project."""
|
||||
config: dict[str, Any] = json.loads((ROOT / ".github/pyrightconfig.json").read_text(encoding="utf-8"))
|
||||
del config["venvPath"], config["venv"]
|
||||
if include is not None:
|
||||
config["include"] = include
|
||||
(root / "pyrightconfig.json").write_text(json.dumps(config), encoding="utf-8")
|
||||
|
||||
def test_compliant_control_passes_and_strict_violations_fail(self) -> None:
|
||||
"""Analyze the control once with no findings and reject incompatible and unannotated code."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
self.project(root)
|
||||
scripts = root / "scripts"
|
||||
scripts.mkdir()
|
||||
probe = scripts / "probe.py"
|
||||
probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
|
||||
clean = guard.run_pyright(root, ".")
|
||||
self.assertEqual(0, clean["exitCode"])
|
||||
guard.check_pyright_report(clean, 1)
|
||||
clean["exitCode"] = 1
|
||||
with self.assertRaisesRegex(ValueError, "exited 1"):
|
||||
guard.check_pyright_report(clean, 1)
|
||||
probe.write_text(INCOMPATIBLE_PYTHON, encoding="utf-8", newline="\n")
|
||||
report = guard.run_pyright(root, ".")
|
||||
self.assertIn("reportAssignmentType", {item.get("rule") for item in report["generalDiagnostics"]})
|
||||
with self.assertRaisesRegex(ValueError, "reported findings"):
|
||||
guard.check_pyright_report(report, 1)
|
||||
probe.write_text(UNANNOTATED_PYTHON, encoding="utf-8", newline="\n")
|
||||
strict = guard.run_pyright(root, ".")
|
||||
self.assertIn("reportMissingParameterType", {item.get("rule") for item in strict["generalDiagnostics"]})
|
||||
|
||||
def test_missing_locked_pyright_or_node_is_rejected(self) -> None:
|
||||
"""Reject a missing locked Pyright entry or Node instead of silently skipping the type gate."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
with mock.patch.object(guard, "PYRIGHT_ENTRY", root / "missing.js"), self.assertRaisesRegex(ValueError, "Locked Pyright is missing"):
|
||||
guard.run_pyright(root, ".")
|
||||
with mock.patch.object(guard.shutil, "which", return_value=None), self.assertRaisesRegex(ValueError, "Node.js is required"):
|
||||
guard.run_pyright(root, ".")
|
||||
|
||||
def test_zero_analyzed_files_cannot_pass(self) -> None:
|
||||
"""Reject an empty include and a hidden-directory root config in the wrapper."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
scripts = root / ".github" / "scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
(scripts / "probe.py").write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
|
||||
for include in (["nothing/*.py"], [".github/scripts/*.py"]):
|
||||
self.project(root, include)
|
||||
with self.subTest(include=include), self.assertRaises(ValueError):
|
||||
guard.check_pyright_report(guard.run_pyright(root, "."), 1)
|
||||
|
||||
|
||||
class JavaScriptProbeTests(unittest.TestCase):
|
||||
"""Run the real locked ESLint and TypeScript on bounded private input."""
|
||||
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
"""Skip only where tsconfig is absent, because the scope guard forbids JavaScript there."""
|
||||
if not (ROOT / "tsconfig.json").is_file():
|
||||
self.skipTest("No JavaScript tooling in this repository")
|
||||
node = shutil.which("node")
|
||||
self.assertIsNotNone(node)
|
||||
self.node = str(node)
|
||||
|
||||
def eslint(self, text: str) -> subprocess.CompletedProcess[str]:
|
||||
"""Lint text as an in-scope tool file using the repository configuration."""
|
||||
return run([self.node, str(ROOT / "node_modules/eslint/bin/eslint.js"), "--stdin", "--stdin-filename", "tools/probe.mjs", "--max-warnings", "0", "--format", "json"], ROOT, text)
|
||||
|
||||
def test_eslint_rejects_wrong_layout(self) -> None:
|
||||
"""Pass a compliant control and reject K&R braces, two-space indent and a missing semicolon."""
|
||||
good = self.eslint(GOOD_JAVASCRIPT)
|
||||
self.assertEqual(0, good.returncode, good.stdout + good.stderr)
|
||||
bad = self.eslint(BAD_LAYOUT_JAVASCRIPT)
|
||||
self.assertNotEqual(0, bad.returncode)
|
||||
rules = {message["ruleId"] for message in json.loads(bad.stdout)[0]["messages"]}
|
||||
self.assertLessEqual({"@stylistic/brace-style", "@stylistic/semi", "@stylistic/indent"}, rules)
|
||||
|
||||
def test_checked_javascript_rejects_incompatible_assignment(self) -> None:
|
||||
"""Reject a real incompatible assignment under the repository strict checkJs settings."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
(root / "tsconfig.json").write_text(json.dumps({"extends": (ROOT / "tsconfig.json").as_posix(), "compilerOptions": {"typeRoots": [(ROOT / "node_modules/@types").as_posix()]}, "include": ["probe.mjs"]}), encoding="utf-8")
|
||||
tsc = [self.node, str(ROOT / "node_modules/typescript/bin/tsc"), "-p", str(root / "tsconfig.json")]
|
||||
(root / "probe.mjs").write_text(GOOD_TYPED_JAVASCRIPT, encoding="utf-8", newline="\n")
|
||||
good = run(tsc, root)
|
||||
self.assertEqual(0, good.returncode, good.stdout + good.stderr)
|
||||
(root / "probe.mjs").write_text(INCOMPATIBLE_JAVASCRIPT, encoding="utf-8", newline="\n")
|
||||
bad = run(tsc, root)
|
||||
self.assertNotEqual(0, bad.returncode)
|
||||
self.assertIn("TS2322", bad.stdout)
|
||||
|
||||
|
||||
def uri_lock(version: str = "4.4.1", integrity: str | None = None, dev: bool = True, direct: bool = False, consumer_dev: bool = True) -> dict[str, Any]:
|
||||
"""Build a minimal package-lock shape with uri-js under an ESLint-like consumer."""
|
||||
root_entry: dict[str, Any] = {"devDependencies": {"uri-js": version} if direct else {"eslint": "10.14.0"}}
|
||||
return {"packages": {"": root_entry, "node_modules/uri-js": {"version": version, "integrity": integrity or guard.URI_JS_INTEGRITY, "dev": dev}, "node_modules/eslint": {"dev": consumer_dev, "dependencies": {"uri-js": "^4.2.2"}}}}
|
||||
|
||||
|
||||
def uri_project(root: Path, lock: dict[str, Any], license_bytes: bytes | None = None) -> None:
|
||||
"""Write a private project with the lock and installed LICENSE that the guard inspects."""
|
||||
(root / "package-lock.json").write_text(json.dumps(lock), encoding="utf-8")
|
||||
installed = root / "node_modules" / "uri-js"
|
||||
installed.mkdir(parents=True)
|
||||
installed.joinpath("LICENSE").write_bytes((ROOT / "node_modules" / "uri-js" / "LICENSE").read_bytes() if license_bytes is None else license_bytes)
|
||||
|
||||
|
||||
class UriJsExceptionTests(unittest.TestCase):
|
||||
"""Allow the EX-0015 license exception only for the reviewed, unmodified, development-only uri-js."""
|
||||
|
||||
def test_reviewed_uri_js_is_accepted(self) -> None:
|
||||
"""Accept the reviewed package with the reviewed installed license bytes."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
uri_project(root, uri_lock())
|
||||
guard.check_uri_js_exception(root)
|
||||
|
||||
def test_other_version_integrity_scope_or_consumer_is_rejected(self) -> None:
|
||||
"""Reject another version, integrity, non-dev or direct use, a non-dev consumer and a second copy."""
|
||||
duplicate = uri_lock()
|
||||
duplicate["packages"]["node_modules/eslint/node_modules/uri-js"] = dict(duplicate["packages"]["node_modules/uri-js"])
|
||||
for lock in (uri_lock(version="4.4.2"), uri_lock(integrity="sha512-other"), uri_lock(dev=False), uri_lock(direct=True), uri_lock(consumer_dev=False), duplicate):
|
||||
with tempfile.TemporaryDirectory() as name, self.subTest(lock=lock), self.assertRaisesRegex(ValueError, "uri-js"):
|
||||
root = Path(name)
|
||||
uri_project(root, lock)
|
||||
guard.check_uri_js_exception(root)
|
||||
|
||||
def test_modified_installed_license_is_rejected(self) -> None:
|
||||
"""Reject installed license bytes that differ from the reviewed copy."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
uri_project(root, uri_lock(), license_bytes=(ROOT / "node_modules" / "uri-js" / "LICENSE").read_bytes() + b"\n")
|
||||
with self.assertRaisesRegex(ValueError, "LICENSE bytes differ"):
|
||||
guard.check_uri_js_exception(root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,7 +11,6 @@ import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("headers", Path(__file__).with_name("check-source-headers.py"))
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
HEADERS = importlib.util.module_from_spec(SPEC)
|
||||
@@ -32,8 +31,7 @@ limitations under the License."""
|
||||
|
||||
def fixture_policy(holder: str = "James Maes", license_id: str = "MIT") -> dict[str, Any]:
|
||||
"""Use the production styles with a small explicit owned fixture scope."""
|
||||
return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"],
|
||||
"notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]}
|
||||
return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"], "notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]}
|
||||
|
||||
|
||||
class HeaderTests(unittest.TestCase):
|
||||
@@ -51,12 +49,23 @@ class HeaderTests(unittest.TestCase):
|
||||
with self.subTest(name=name):
|
||||
HEADERS.check_notice(name, text, fixture_policy())
|
||||
|
||||
def test_year_range_separator_is_hyphen_or_en_dash_only(self) -> None:
|
||||
"""Accept a hyphen or en dash between years and reject any other separator."""
|
||||
for separator, accepted in (("-", True), (chr(0x2013), True), ("/", False), (chr(0x2014), False)):
|
||||
text = f"# Copyright 2025{separator}2026 James Maes\n# SPDX-License-Identifier: MIT"
|
||||
with self.subTest(separator=separator):
|
||||
if accepted:
|
||||
HEADERS.check_notice("src/a.py", text, fixture_policy())
|
||||
else:
|
||||
with self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.py", text, fixture_policy())
|
||||
|
||||
def test_required_first_lines_and_bom(self) -> None:
|
||||
"""Accept shebangs, PowerShell requirements and XML declarations first."""
|
||||
for name, prefix, body in (
|
||||
("src/a.sh", "#!/bin/sh\n", "# " + MIT.replace("\n", "\n# ")),
|
||||
("src/a.ps1", "#Requires -Version 7.4\n", "# " + MIT.replace("\n", "\n# ")),
|
||||
("src/a.xml", "<?xml version=\"1.0\"?>\n", "<!--" + MIT + "--><root/>"),
|
||||
("src/a.xml", '<?xml version="1.0"?>\n', "<!--" + MIT + "--><root/>"),
|
||||
("src/a.mjs", "#!/usr/bin/env node\n", "/*" + MIT + "*/"),
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
@@ -67,21 +76,20 @@ class HeaderTests(unittest.TestCase):
|
||||
for name, text in (
|
||||
("src/a.sh", "# " + MIT.replace("\n", "\n# ") + "\n#!/bin/sh"),
|
||||
("src/a.ps1", "# " + MIT.replace("\n", "\n# ") + "\n#Requires -Version 7.4"),
|
||||
("src/a.xml", "<!--" + MIT + "--><?xml version=\"1.0\"?><root/>"),
|
||||
("src/a.xml", "<!--" + MIT + '--><?xml version="1.0"?><root/>'),
|
||||
):
|
||||
with self.subTest(name=name), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice(name, text, fixture_policy())
|
||||
|
||||
def test_code_strings_and_late_comments_do_not_count(self) -> None:
|
||||
"""Reject docstrings and comments after executable declarations."""
|
||||
for text in ("notice = \"" + MIT.replace("\n", "\\n") + "\"",
|
||||
"\"\"\"" + MIT + "\"\"\"", "import os\n# " + MIT.replace("\n", "\n# ")):
|
||||
for text in ('notice = "' + MIT.replace("\n", "\\n") + '"', '"""' + MIT + '"""', "import os\n# " + MIT.replace("\n", "\n# ")):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.py", text, fixture_policy())
|
||||
|
||||
def test_wrong_syntax_and_unterminated_comments_fail(self) -> None:
|
||||
"""Notice text alone is insufficient without a complete language comment."""
|
||||
for text in ("# " + MIT, "/*" + MIT, "string text = \"" + MIT + "\";"):
|
||||
for text in ("# " + MIT, "/*" + MIT, 'string text = "' + MIT + '";'):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.cs", text, fixture_policy())
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -89,8 +97,7 @@ class HeaderTests(unittest.TestCase):
|
||||
|
||||
def test_wrong_holder_or_license_and_conflicting_spdx_fail(self) -> None:
|
||||
"""Do not conflate repository licenses or accept conflicting identifiers."""
|
||||
for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"),
|
||||
MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]):
|
||||
for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"), MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.cs", "/*" + text + "*/", fixture_policy())
|
||||
|
||||
@@ -103,8 +110,7 @@ class HeaderTests(unittest.TestCase):
|
||||
def test_java_requires_full_apache_block(self) -> None:
|
||||
"""An abbreviated SPDX-only Java notice cannot replace scoped convention."""
|
||||
policy = fixture_policy("Kingsrook, LLC", "Apache-2.0")
|
||||
for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0",
|
||||
APACHE.replace("limitations under the License.", "")):
|
||||
for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0", APACHE.replace("limitations under the License.", "")):
|
||||
with self.subTest(notice=notice), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/A.java", "/*" + notice + "*/", policy)
|
||||
|
||||
@@ -121,9 +127,7 @@ class HeaderTests(unittest.TestCase):
|
||||
"""New scripts enter the guard; data, generated and upstream files do not."""
|
||||
self.assertTrue(HEADERS.selected(".github/scripts/new-tool.py", POLICY))
|
||||
self.assertTrue(HEADERS.selected(".github/workflows/new-check.yml", POLICY))
|
||||
for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json",
|
||||
"src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml",
|
||||
"src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"):
|
||||
for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json", "src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml", "src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"):
|
||||
with self.subTest(name=name):
|
||||
self.assertFalse(HEADERS.selected(name, POLICY))
|
||||
self.assertTrue(all(item["reason"].strip() for item in POLICY["exclusions"]))
|
||||
@@ -140,11 +144,11 @@ class HeaderTests(unittest.TestCase):
|
||||
"""A tracked new source fails while untracked generated output is ignored."""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
||||
subprocess.run(["git", "init", "-q", str(root)], check=True) # noqa: S603, S607 -- Fixed Git command in the owned temporary test directory.
|
||||
root.joinpath("src").mkdir()
|
||||
source = root / "src/a.py"
|
||||
source.write_text("print(1)", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True)
|
||||
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True) # noqa: S603, S607 -- Fixed synthetic path; never a shell command.
|
||||
with self.assertRaisesRegex(ValueError, "src/a.py"):
|
||||
HEADERS.check_repository(root, fixture_policy())
|
||||
source.write_text("# " + MIT.replace("\n", "\n# ") + "\nprint(1)", encoding="utf-8")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"includes": [
|
||||
".github/*.yml",
|
||||
".github/*.yaml",
|
||||
".gitea/*.yaml",
|
||||
".github/scripts/*.py",
|
||||
"tools/*.mjs"
|
||||
],
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: Protocol CI and release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '24'
|
||||
package-manager-cache: false
|
||||
- run: npm ci --ignore-scripts
|
||||
- name: Verify owned source notices and rejection paths
|
||||
run: |
|
||||
python3 .github/scripts/test-source-headers.py
|
||||
python3 .github/scripts/check-source-headers.py
|
||||
- run: npm run check
|
||||
- run: npm run bundle
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: protocol-bundle
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
release-security:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: ./.github/workflows/security.yml
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
release-codeql:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: ./.github/workflows/codeql.yml
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [verify, release-security, release-codeql]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: protocol-bundle
|
||||
path: dist
|
||||
- name: Verify packaged checksums
|
||||
working-directory: dist
|
||||
run: sha256sum --check --strict SHA256SUMS
|
||||
- name: Attest verified release artifacts
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: dist/*
|
||||
- name: Publish versioned GitHub release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
prerelease=()
|
||||
if [[ "$GITHUB_REF_NAME" == *-* ]]; then prerelease+=(--prerelease); fi
|
||||
gh release create "$GITHUB_REF_NAME" dist/*.tar.gz dist/SHA256SUMS \
|
||||
--verify-tag --title "$GITHUB_REF_NAME" --notes-file CHANGELOG.md "${prerelease[@]}"
|
||||
@@ -1,128 +0,0 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Local copy of KofTwentyTwo/standards at fdf5ed029603c938615a32b1beca3628b8787945 (v0.1.2).
|
||||
# Pins, inputs and analysis checks preserved; report validation now fails closed.
|
||||
# Static analysis (K22-CI-22): CodeQL with the security-extended suite, failing on
|
||||
# alerts of high severity or above. Called as:
|
||||
#
|
||||
# jobs:
|
||||
# codeql:
|
||||
# uses: KofTwentyTwo/standards/.github/workflows/codeql.yml@<sha> # vX.Y.Z
|
||||
# permissions:
|
||||
# contents: read
|
||||
# security-events: write
|
||||
# with:
|
||||
# languages: '["csharp", "actions"]'
|
||||
# build-mode: manual
|
||||
# build-command: dotnet build MySolution.slnx -p:Platform=x64
|
||||
# runs-on: windows-latest
|
||||
#
|
||||
# producing `codeql / analyze (<language>)` for each language. The CodeQL action
|
||||
# uploads results but never fails a build on them, so the last step reads the SARIF
|
||||
# and fails on any result whose rule has security-severity >= the threshold.
|
||||
# Suppress a false positive with a query filter in a CodeQL config file passed as
|
||||
# `config-file`, citing the exception record in a comment.
|
||||
name: codeql
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
languages:
|
||||
description: JSON array of CodeQL languages, e.g. '["csharp","actions"]'.
|
||||
type: string
|
||||
required: true
|
||||
build-mode:
|
||||
description: CodeQL build mode for compiled languages (none, autobuild, manual). Interpreted languages always use none.
|
||||
type: string
|
||||
default: autobuild
|
||||
build-command:
|
||||
description: Command run for build-mode manual.
|
||||
type: string
|
||||
default: ""
|
||||
runs-on:
|
||||
description: Runner label; Windows-only builds (WinUI, WPF) need windows-latest.
|
||||
type: string
|
||||
default: ubuntu-24.04
|
||||
setup-dotnet:
|
||||
description: Install the .NET SDK from global.json before building.
|
||||
type: boolean
|
||||
default: false
|
||||
config-file:
|
||||
description: Optional path to a CodeQL configuration file in the calling repository.
|
||||
type: string
|
||||
default: ""
|
||||
fail-on-security-severity:
|
||||
description: Minimum CVSS-style security-severity that fails the job (7.0 = high).
|
||||
type: string
|
||||
default: "7.0"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: analyze (${{ matrix.language }})
|
||||
runs-on: ${{ inputs.runs-on }}
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # upload SARIF results to code scanning
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ${{ fromJSON(inputs.languages) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Test scan guard rejection paths
|
||||
shell: python
|
||||
run: |
|
||||
import runpy
|
||||
runpy.run_path(".github/scripts/test-scan-gates.py", run_name="__main__")
|
||||
|
||||
- name: Set up .NET SDK
|
||||
if: inputs.setup-dotnet
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
global-json-file: global.json
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ contains(fromJSON('["actions","javascript-typescript","python","ruby"]'), matrix.language) && 'none' || inputs.build-mode }}
|
||||
queries: security-extended
|
||||
config-file: ${{ inputs.config-file }}
|
||||
|
||||
- name: Build (manual mode)
|
||||
if: inputs.build-mode == 'manual' && !contains(fromJSON('["actions","javascript-typescript","python","ruby"]'), matrix.language)
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_COMMAND: ${{ inputs.build-command }}
|
||||
run: |
|
||||
if [[ -z "$BUILD_COMMAND" ]]; then
|
||||
echo "::error::build-mode is manual but build-command is empty"
|
||||
exit 1
|
||||
fi
|
||||
eval "$BUILD_COMMAND"
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
output: codeql-sarif
|
||||
# Fork pull requests have a read-only token; analyze still runs and gates.
|
||||
upload: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'never' || 'always' }}
|
||||
|
||||
- name: Gate on security severity
|
||||
shell: python
|
||||
env:
|
||||
SARIF_DIR: codeql-sarif
|
||||
THRESHOLD: ${{ inputs.fail-on-security-severity }}
|
||||
EXPECTED_LANGUAGE: ${{ matrix.language }}
|
||||
run: |
|
||||
import runpy
|
||||
runpy.run_path(".github/scripts/check-codeql-sarif.py", run_name="__main__")
|
||||
@@ -1,21 +0,0 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: codeql
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '41 6 * * 1'
|
||||
workflow_call:
|
||||
permissions: {}
|
||||
jobs:
|
||||
codeql:
|
||||
uses: ./.github/workflows/codeql-analysis.yml
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
with:
|
||||
languages: '["javascript-typescript", "actions"]'
|
||||
build-mode: none
|
||||
@@ -1,23 +0,0 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Pull request gates (K22-CI-01): Conventional Commit title, DCO sign-off, and
|
||||
# dependency review, from the shared KofTwentyTwo/standards workflow pinned by SHA
|
||||
# (K22-CI-11). Dependabot's github-actions ecosystem keeps the pin current.
|
||||
name: pr-gates
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: pr-gates-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pr:
|
||||
uses: KofTwentyTwo/standards/.github/workflows/pr.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
|
||||
permissions:
|
||||
contents: read # read the commits being merged
|
||||
pull-requests: read # read the pull request title and dependency diff
|
||||
@@ -1,26 +0,0 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: security
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '23 6 * * 1'
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
uses: KofTwentyTwo/standards/.github/workflows/security.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
@@ -4,3 +4,8 @@ dist/
|
||||
.env
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local script-quality environments and tool caches.
|
||||
.venv/
|
||||
.ruff_cache/
|
||||
__pycache__/
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.1-rc.1
|
||||
|
||||
Release-pipeline candidate: no schema, fixture or wire change from 1.1.0. The
|
||||
first release built and published by Gitea CI from the tag (bundle, `SHA256SUMS`
|
||||
and these notes), replacing the hand-published Gitea release and the retired
|
||||
GitHub release with attestation.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
Additive contract for printing semantic extension **2.4.0**. Wire payload stays
|
||||
**v1** and the subprotocol stays `qqq-printing.v1`; every 2.3.0 fixture is unchanged.
|
||||
|
||||
- Add the `printer-approval-v1` capability to `RealtimeHelloPayload` (`maxItems` 5)
|
||||
and `ControlCapability`; the historical control poll and management
|
||||
`Device.control` accept four non-realtime capabilities.
|
||||
- Add the server → client `PRINTERS` frame (`RealtimePrinters`,
|
||||
`RealtimePrintersPayload`, `PrinterApprovalState`) carrying the complete approval
|
||||
state of the device's latest accepted inventory; sent only to connections that
|
||||
advertised `printer-approval-v1`, after each accepted INVENTORY and on every
|
||||
approval change. Not acknowledged, not durable, no job or document data.
|
||||
- Add `realtime-printers.message.json`, a five-capability hello and a 2.4.0
|
||||
discovery fixture; the verifiers cover the directional frame, bounds and
|
||||
required fields. See [REALTIME.md](docs/REALTIME.md#printer-approval) and the
|
||||
[version registry](docs/VERSIONS.md).
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Distribution version 1.0.0 of the contract and fixtures from 0.4.0-rc.1. The wire
|
||||
protocol stays **v1** and the printing extension stays **2.3.0** with the client-initiated
|
||||
realtime transport; no schema or fixture changes. 1.0.0 is a distribution version, not a
|
||||
wire change.
|
||||
|
||||
## 0.4.0-rc.1 — unreleased
|
||||
|
||||
- Add bounded machine-default media profiles and exact job profile/revision
|
||||
selection; refuse ambiguous dimension overrides.
|
||||
- Define the least-privilege operator catalog, submit and read-only request
|
||||
recovery contracts, including stable actor-scoped request keys.
|
||||
- Add `printer-media-v1` capability negotiation; selected-profile jobs require
|
||||
a supporting current client, including after capability downgrade/reconnect.
|
||||
- Retain wire version 1 and the realtime transport. Printing extension 2.3.0
|
||||
advertises the additive native routes; 2.2.0 is the separate rendering extension
|
||||
and carries no media negotiation. Consumer runtime, installed and physical
|
||||
acceptance remain required before a coordinated release.
|
||||
- Add the [extension version registry](docs/VERSIONS.md). No schema or fixture shape
|
||||
changed for the 2.2 to 2.3 reservation.
|
||||
- Add `LABEL_4X2_*` and `LABEL_4X1_*` test profiles (203/300/600 dpi) with a 4 × 1
|
||||
fixture; the 4 × 2 and 4 × 1 PDFs carry explicit actual-size job paper and never a
|
||||
`mediaProfile`. Both consumers must come from this cohort.
|
||||
|
||||
## 0.3.0-rc.1 — 2026-10-31
|
||||
|
||||
Upgrade the server before the matching Windows connector. Native printing API
|
||||
|
||||
+19
-4
@@ -1,11 +1,26 @@
|
||||
# Contributing
|
||||
|
||||
Use Node.js 24 or newer. Install the locked development dependencies with `npm ci`, then run `npm run check`. The runtime protocol has no JavaScript dependency; Ajv and ajv-formats are development-only JSON Schema validators.
|
||||
Use Node.js 24 or newer. Install the locked development dependencies with `npm ci`, then run `npm run check` (`npm test` is the standard entry point for the same check). The runtime protocol has no JavaScript dependency; Ajv and ajv-formats are development-only JSON Schema validators.
|
||||
|
||||
Make changes through a pull request. Describe the client/server behavior affected and update README, schema, fixtures and changelog together. Keep credentials, customer documents, printer addresses and machine-specific data out of fixtures. Samples must remain synthetic.
|
||||
Make changes through a pull request to `main` on the [primary Gitea repository](https://git.kof22.com/QRun-IO/qqq-print-protocol). GitHub is a readable shadow; do not open ordinary development PRs or publish releases there. Describe the client/server behavior affected and update README, schema, fixtures and changelog together. Keep credentials, customer documents, printer addresses and machine-specific data out of fixtures. Samples must remain synthetic.
|
||||
|
||||
Run both the Java and .NET consumers' protocol tests for a contract change. Check out sibling `qbit-printing`, `qqq-print-agent` and this repository; see [integration notes](docs/INTEGRATION.md). Coordinate consumer changes before merging a breaking change.
|
||||
|
||||
Keep wire names stable within `/printing/v1`. Optional response fields may be added; clients ignore unknown response fields. Removing/renaming a field, changing its type or changing an existing field's meaning requires a new protocol version. A prerelease package version is not permission to silently break an already deployed connector.
|
||||
Keep wire-v1 payload names stable for native `/qqq/v1/qbit/printing` and explicit legacy `/printing/v1` routes. Optional response fields may be added; clients ignore unknown response fields. Removing/renaming a field, changing its type or changing an existing field's meaning requires explicit versioned compatibility design. Package, printing extension, wire and WebSocket subprotocol versions are independent; see [the version registry](docs/VERSIONS.md). A package version is not permission to silently break an already deployed connector.
|
||||
|
||||
`npm run bundle` produces the release archive and checksum in `dist/`. Do not hand-edit generated checksums. Release tags must match the version in package.json, prefixed with `v`. Commits and releases are made by maintainers after review.
|
||||
`npm run bundle` produces the release archive and checksum in `dist/`. Do not hand-edit generated checksums. Release tags must match the version in package.json, prefixed with `v`. A `v<version>` tag on the reviewed merge commit makes Gitea CI rerun every check, build the bundle and create the [Gitea release](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases) with the archive, a fresh `SHA256SUMS` (the bundle's own is checked first) and the matching CHANGELOG section as notes (a `-rc.N` version is a prerelease). Gitea has no equivalent of the retired GitHub artifact attestation: verify downloads against `SHA256SUMS` and the tag. GitHub Actions for this repository were retired on 2026-11-04; retained `.github/scripts` remain local/shared-CI tooling. See [current release provenance](docs/INTEGRATION.md#stable-printing-release) before updating consumer fixture pins.
|
||||
|
||||
## Script quality
|
||||
|
||||
Owned scripts follow the KofTwentyTwo standard at revision `fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152` (configs adapted from its `python` and `typescript` profiles): three-space indentation, Ruff 0.17.3 format/lint (locked in `uv.lock`) and strict Pyright 1.1.414 (Microsoft's npm package, locked in `package-lock.json` and run through Node) for `.github/scripts/*.py`, and ESLint 10 with Stylistic (Allman) plus strict `tsc` over JSDoc for `tools/**/*.mjs` and `eslint.config.mjs`. Restore with `uv sync --locked` and `npm ci --ignore-scripts`, then run:
|
||||
|
||||
```text
|
||||
uv run --locked --no-sync python .github/scripts/test-script-quality.py
|
||||
uv run --locked --no-sync python .github/scripts/check-script-quality.py
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
`npm run quality` runs the repository's Python sequence in one step (`uv sync --locked`, both script-quality commands and both source-header commands); Gitea CI (`.gitea/workflows/build.yaml`, the shared ci-workflows `node.yaml`) runs the same commands individually, then typecheck, lint, check and bundle. It needs `uv` on `PATH` and `npm ci` first.
|
||||
|
||||
`check-script-quality.py` fails for any tracked Python outside `.github/scripts/` or JavaScript outside the `tsconfig.json` include list, and requires Pyright's analyzed-file count to equal the selected scripts. Pyright must run as `pyright -p .github`: it silently skips dot directories from a root config. Documented exceptions: CLI `print`/`console.log` only in `check-*.py` and `tools/`; synthetic test assertions and boundary constants; narrow inline `noqa` comments for fixed no-shell Git/interpreter calls and ordered fail-closed validators. Do not loosen a rule or add a type waiver to pass. `package.json` overrides `minimatch` to 11.0.1, the patched release for [GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249) (affected before 11.0.1); keep it until ESLint's own dependency range includes a patched release. The only npm license exception, [EX-0015](https://github.com/KofTwentyTwo/standards/blob/main/exceptions/register.md#ex-0015) for development-only `uri-js` 4.4.1, is enforced by `check-script-quality.py` (version, lock integrity and installed license bytes); changing that package needs renewed approval.
|
||||
@@ -1,8 +1,40 @@
|
||||
# QQQ Print Protocol v1
|
||||
|
||||
Shared contract for the [QQQ 4.1+ printing QBit](https://github.com/QRun-IO/qbit-printing) and [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent). The coordinated [realtime extension 2.0](docs/REALTIME.md) uses a client-initiated authenticated WebSocket for controls, receipts and job notifications, with separate streamed HTTPS documents and no polling fallback. This contract is authoritative for wire names; language types may use their idiomatic naming.
|
||||
Shared contract for the [QQQ 4.1+ printing QBit](https://git.kof22.com/QRun-IO/qbit-printing) and [Windows AppKit connector](https://git.kof22.com/QRun-IO/qqq-print-agent). The [realtime transport](docs/REALTIME.md) uses a client-initiated authenticated two-way WebSocket for controls, receipts and job notifications, with separate streamed HTTPS documents and no polling fallback. No inbound connection to the facility is required. This contract is authoritative for wire names; language types may use their idiomatic naming.
|
||||
|
||||
Current release candidate: [**0.3.0-rc.1**](https://github.com/QRun-IO/qqq-print-protocol/releases/tag/v0.3.0-rc.1). Previous distribution: [**0.2.0-rc.1**](https://github.com/QRun-IO/qqq-print-protocol/releases/tag/v0.2.0-rc.1). Wire protocol: **v1**. This repository supplies specification, JSON Schema and synthetic compatibility fixtures; install the Windows connector and integrate the QBit from their own repositories to print documents.
|
||||
Published distribution: [**1.1.0**](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.1.0). Wire protocol: **v1**. The stable cohort pairs [Windows 1.2.1](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1) with [QBit 1.1.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.0), printing semantic API **2.4.0**, and QQQ **4.1.0-RC.21**; the [1.0.0 cohort](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.0.0) (Windows 1.0.1, QBit 1.0.0, API 2.3.0) stays compatible. See [release and fixture provenance](docs/INTEGRATION.md#stable-printing-release). This repository supplies specification, JSON Schema and synthetic compatibility fixtures; install the Windows connector and integrate the QBit from their own repositories to print documents.
|
||||
|
||||
The stable release includes the [printer media and operator
|
||||
contract](docs/PRINTER-MEDIA.md) for printing semantic extension **2.3.0** (see the
|
||||
[extension version registry](docs/VERSIONS.md); 2.2 is rendering only). Media profiles
|
||||
represent configured Windows queue defaults with exact revision binding;
|
||||
operator access uses normal current-user and tenant authorization without
|
||||
granting device management. Use the stable cohort above for these profiles;
|
||||
older 0.3.2 clients do not support them. Wire version 1 and the client-initiated
|
||||
realtime transport stay the same.
|
||||
|
||||
Distribution **1.1.0** adds printing semantic extension **2.4.0**:
|
||||
the `printer-approval-v1` HELLO capability and the server → client `PRINTERS`
|
||||
frame, which reports each exposed printer's server approval over the
|
||||
[realtime channel](docs/REALTIME.md#printer-approval). Clients advertise it only
|
||||
after discovering a stable 2.4+ extension; a 2.3.0 server or a client without the
|
||||
capability keeps the 2.3.0 wire unchanged.
|
||||
|
||||
## Current connection and delivery
|
||||
|
||||
1. Configure the QQQ server origin in the Windows app. Discover its UI/UX API and sign in using the server's configured authentication method.
|
||||
2. Enroll the computer under that user and select which local Windows queues to expose. Approve the intended queues in QQQ.
|
||||
3. The client opens the authenticated WebSocket, synchronizes settings and desired state, then listens for committed job and control events.
|
||||
4. A job notification starts an authenticated HTTPS claim and streamed document download. Controls remain responsive during large transfers.
|
||||
5. The client verifies the document, journals submission and sends durable status receipts over the socket. A lost connection automatically reconnects and reconciles pending work without blindly printing it again.
|
||||
|
||||
QQQ user, tenant, table, process and record permissions remain authoritative.
|
||||
Document limits are configurable: the realtime defaults are 100 MiB for PDF and
|
||||
20 MiB for ZPL. See [authentication](docs/QQQ-AUTHENTICATION.md),
|
||||
[operational settings](docs/REALTIME.md#operational-settings-and-client-ceilings),
|
||||
and [printer media selection](docs/PRINTER-MEDIA.md).
|
||||
|
||||
## Development
|
||||
|
||||
Use Node.js 24+ to run the development checks:
|
||||
|
||||
@@ -31,7 +63,7 @@ Use `Content-Type: application/json` except the content-download response. The e
|
||||
|
||||
The host application authenticates management requests and determines authorized tenant/facility scope. A standalone local test host may use a configured administrative bearer credential. Device ownership, tenant and facility are taken from server records, not trusted request fields.
|
||||
|
||||
## Endpoints
|
||||
## Historical compatibility endpoints
|
||||
|
||||
| Method/path | Request | Successful response |
|
||||
| --- | --- | --- |
|
||||
@@ -49,14 +81,14 @@ The host application authenticates management requests and determines authorized
|
||||
| GET `/admin/jobs` | none | 200 `{ "jobs": [PrintJob] }` |
|
||||
| GET `/admin/jobs/{jobId}` | none | 200 `PrintJob` |
|
||||
|
||||
Device lists and job lists are scoped by host authorization. Listing pagination and destination/profile CRUD are beyond this first wire contract. Native application code may submit through the QBit service rather than the management HTTP API.
|
||||
Device lists and job lists are scoped by host authorization. Bounded job/printer pagination is described below; destination/profile CRUD is outside this wire contract. Native application code may submit through the QBit service or the authorized [operator API](docs/PRINTER-MEDIA.md).
|
||||
|
||||
The additive [device-control contract](docs/DEVICE-CONTROLS.md) defines capability
|
||||
discovery, dispatch pause/resume, operational disconnect/reconnect, permanent
|
||||
credential revocation, inventory refresh, queued-job clearing, and exact owned
|
||||
Windows spool cancellation. It uses durable request identities, bounded command
|
||||
polling, and explicit receipts. These additions are included in the
|
||||
`0.2.0-rc.1` distribution, with 42 definitions and 54 validated fixtures.
|
||||
Windows spool cancellation. It uses durable request identities and explicit
|
||||
receipts over the realtime channel. Historical command-polling DTOs remain for
|
||||
explicit compatibility; they are not the stable client's transport.
|
||||
|
||||
## DTOs
|
||||
|
||||
@@ -68,7 +100,7 @@ polling, and explicit receipts. These additions are included in the
|
||||
* `EnrollResponse`: `protocolVersion` (1), `deviceId`, `deviceToken`, `pollIntervalSeconds` (default 5).
|
||||
* `HeartbeatRequest`: `agentVersion`. Response: `serverTime`.
|
||||
* `PrinterInventory`: full snapshot of the queues selected for cloud exposure in the local Windows companion, in `printers`. Each printer contains stable `printerId`, exact Windows queue `name`, `driverName`, `portName`, `status`, `formats`. An empty list withdraws every queue. There is no new required exposure field: presence in the snapshot expresses local exposure.
|
||||
* `PrintOptions`: `copies` defaults to 1; `scale` defaults to `ACTUAL_SIZE`. The schema reserves optional `paperWidthMm`, `paperHeightMm` (which must appear together), but the initial implementation rejects them until per-job paper configuration is implemented. Configure paper on the Windows queue for now. First implementation supports `ACTUAL_SIZE` and `FIT`. ZPL dimensions are encoded in its payload; copies must be applied exactly once.
|
||||
* `PrintOptions`: `copies` defaults to 1; `scale` defaults to `ACTUAL_SIZE`. Optional `paperWidthMm`, `paperHeightMm` (which must appear together) request actual-size job paper for PDF jobs; ZPL encodes its dimensions in the payload, and a `mediaProfile` selection cannot be combined with them. Without them, the Windows queue default applies. First implementation supports `ACTUAL_SIZE` and `FIT`. ZPL dimensions are encoded in its payload; copies must be applied exactly once.
|
||||
* `SubmitJobRequest`: `deviceId`, `printerId`, `title`, `format`, `contentBase64`, `idempotencyKey`, optional `options`.
|
||||
* `PrintJob`: `jobId`, `printerId`, `title`, `format`, lowercase hex `contentSha256`, `contentLength` (decoded bytes), normalized `options`, `createdAt`, `status`. Management responses additionally include `deviceId` and may include latest `message` and `spoolerJobId`.
|
||||
* `JobEvent`: stable `eventId`, `status`, optional `message`, optional string `spoolerJobId`.
|
||||
@@ -88,7 +120,7 @@ Formats: `PDF` (`application/pdf`) and `ZPL` (`application/vnd.zebra-zpl`). Cont
|
||||
6. Connector persists the job locally, reports RECEIVED, downloads `/device/jobs/{jobId}/content`, verifies byte length/hash, and validates the format/printer mapping.
|
||||
7. Connector records SUBMITTING locally and reports it before invoking Windows. After success it records and reports SPOOLED, including a spooler reference when available. Submission must not be presented as physical print confirmation.
|
||||
|
||||
Content downloads derive authorization from the authenticated device and job assignment. They never accept arbitrary URLs. Default decoded document limit is 20 MiB. Copies range from 1 to 100. Reject empty documents, unsupported formats and unsupported printer capabilities before queuing.
|
||||
Content downloads derive authorization from the authenticated device and job assignment. They never accept arbitrary URLs. The historical HTTP defaults were 20 MiB and 100 copies; current native limits are the configurable policy in [REALTIME.md](docs/REALTIME.md). Reject empty documents, unsupported formats and unsupported printer capabilities before queuing.
|
||||
|
||||
## Minimal delivery rules
|
||||
|
||||
@@ -148,11 +180,11 @@ Error body: `{ "code": "ERROR_CODE", "message": "Human-readable explanation" }`.
|
||||
|
||||
`npm run check` compiles every JSON Schema 2020-12 definition with Ajv and validates all shared fixtures, then checks ZPL bytes/hash/base64 and representative invalid inputs. The two pinned development dependencies are solely for standards-compliant schema validation; they do not enter either printing implementation's runtime.
|
||||
|
||||
`node tools/verify-fixtures.mjs` remains a dependency-free subset check. Java and .NET implementations should consume these exact fixtures in protocol tests, pinned to a reviewed immutable protocol commit. The Java HTTP round trip and .NET fixture compatibility checks passed during initial development. CI configuration is included; a local check does not imply that GitHub CI has run.
|
||||
`node tools/verify-fixtures.mjs` remains a dependency-free subset check. Java and .NET implementations consume shared fixtures pinned to a reviewed immutable protocol commit. The Java HTTP round trip and .NET fixture compatibility checks passed during initial development. Gitea is the primary CI and release service; the protocol repository's GitHub Actions were retired on 2026-11-04. See [release and fixture provenance](docs/INTEGRATION.md#stable-printing-release) for the retained Windows fixture bridge and the scope of release evidence.
|
||||
|
||||
## Branded test-document profiles
|
||||
|
||||
`DeviceTestPrintRequest` optionally includes `testProfile`. PDF accepts `LETTER`, `A4`, or a `LABEL_4X6_*` profile (all label profiles produce the same 101.6 × 152.4 mm PDF). ZPL accepts `LABEL_4X6_203`, `LABEL_4X6_300`, or `LABEL_4X6_600`; the suffix selects the printer resolution in dots per inch. Use possible-value selectors in operator interfaces and match the loaded stock and device resolution. The profile defines document layout; it does not reconfigure Windows paper stock.
|
||||
`DeviceTestPrintRequest` optionally includes `testProfile`. PDF accepts `LETTER`, `A4`, or a `LABEL_4X6_*`, `LABEL_4X2_*` or `LABEL_4X1_*` profile (all profiles of one height produce the same PDF: 101.6 × 152.4, 50.8 or 25.4 mm). ZPL accepts those label profiles with `_203`, `_300` or `_600`; the suffix selects the printer resolution in dots per inch. For the 4 × 2 and 4 × 1 PDFs the server also submits `options.paperWidthMm`/`paperHeightMm` (101.6 × 50.8 or 25.4) so the client sets actual-size job paper; they are never combined with `mediaProfile`. The 4 × 6 PDF and all ZPL labels carry no job paper. These profiles are included in the stable cohort above; older agents may reject job paper. Use possible-value selectors in operator interfaces and match the loaded stock and device resolution. The profile defines document layout; it does not reconfigure Windows paper stock.
|
||||
|
||||
Requests that omit the field retain compatible defaults: PDF uses Letter, ZPL uses 4 × 6 at 203 dpi. An explicit null, unknown profile, or Letter/A4 with ZPL is invalid. A stable requestId protects transport retries; changing document media is a new operator request. Each action queues one test on each explicitly selected destination, subject to the existing exposure and approval checks. No discovery or connection event automatically prints.
|
||||
|
||||
|
||||
+2
-2
@@ -2,6 +2,6 @@
|
||||
|
||||
This repository specifies the QQQ printing wire boundary. Implementations must authenticate every device operation, bind jobs and content to the enrolled device, restrict administration separately, and avoid automatically reprinting uncertain submissions.
|
||||
|
||||
Report vulnerabilities privately using [GitHub private vulnerability reporting](https://github.com/QRun-IO/qqq-print-protocol/security/advisories/new). If that channel is unavailable, contact `contact@kingsrook.com`. Do not publish working device tokens, enrollment codes, customer print content or exploit details in a public issue.
|
||||
Report vulnerabilities privately using [GitHub private vulnerability reporting](https://github.com/QRun-IO/qqq-print-protocol/security/advisories/new). If that channel is unavailable, contact `contact@kingsrook.com`. This private reporting channel remains available while [Gitea](https://git.kof22.com/QRun-IO/qqq-print-protocol) is primary for source, pull requests and releases. Do not publish working device tokens, enrollment codes, customer print content or exploit details in a public issue on either forge.
|
||||
|
||||
Fixes target the current prerelease and protocol v1 consumers; there is no separate legacy support branch. TLS, token storage, host permissions and printer-command restrictions are implemented and assessed in the server and Windows repositories.
|
||||
Fixes target stable protocol 1.0.0 and supported wire-v1 consumers; there is no separate legacy support branch. TLS, token storage, host permissions and printer-command restrictions are implemented and assessed in the [server](https://git.kof22.com/QRun-IO/qbit-printing) and [Windows](https://git.kof22.com/QRun-IO/qqq-print-agent) repositories. Native printing requires the current QQQ user session plus secondary device proof; a transport failure never permits an authentication or polling fallback.
|
||||
@@ -1,5 +1,10 @@
|
||||
# Workflow compatibility — 2026-10-30
|
||||
|
||||
Historical decision retained unchanged below. Protocol GitHub Actions were
|
||||
retired on 2026-11-04 at 01:11 UTC; Gitea is now the primary CI and release
|
||||
service. This snapshot does not describe current upstream tool versions or a
|
||||
new hosted result. See [current publication and fixture provenance](INTEGRATION.md#stable-printing-release).
|
||||
|
||||
The `zizmor/self-repository` advisory recommends GitHub's newer `$/` syntax. This repository currently uses `./.github/workflows/...` **only for job-level reusable workflow calls**. GitHub documents that both forms select the same commit as the calling workflow. These are not step-level local actions that resolve from a potentially different workspace checkout.
|
||||
|
||||
The latest released actionlint, **1.7.12**, rejects `$/` reusable workflow calls. This was reproduced locally with the pinned version; the unchanged `./` calls pass. The upstream compatibility issue is still open. Retaining the supported job-level form preserves full actionlint validation of the called workflow, including its inputs and permissions, without suppressing the syntax rule or disabling any security check.
|
||||
|
||||
+17
-14
@@ -1,6 +1,6 @@
|
||||
# Device controls — additive wire v1
|
||||
|
||||
For the coordinated printing extension 2.0 implementation, these domain state,
|
||||
For the stable printing cohort (native extension 2.3.0), these domain state,
|
||||
command and receipt semantics use [direct realtime frames](REALTIME.md).
|
||||
The HTTP polling routes below describe the earlier 1.0/explicit compatibility
|
||||
transport and are never a fallback for a realtime client.
|
||||
@@ -8,13 +8,14 @@ transport and are never a fallback for a realtime client.
|
||||
This extension provides server dispatch controls and two bounded Windows agent
|
||||
commands. Its additive payloads preserve existing print job states and old
|
||||
request bodies. These domain controls were published in package `0.2.0-rc.1`;
|
||||
their realtime transport is part of release candidate `0.3.0-rc.1`. This contract does
|
||||
not establish deployment or production readiness.
|
||||
their realtime transport was introduced in `0.3.0-rc.1` and is included in
|
||||
[stable protocol 1.0.0](INTEGRATION.md#stable-printing-release). Deployment and physical output evidence
|
||||
belong to the implementation repositories.
|
||||
|
||||
## Discovery and routes
|
||||
|
||||
The primary transport is the discovered QQQ semantic extension at
|
||||
`/qqq/v1/qbit/printing`, extension version `1.0.0`, using channel
|
||||
`/qqq/v1/qbit/printing`, extension version `>=2.0.0 <3.0.0`, using channel
|
||||
`api/qqq-print-agent`. Standard QQQ session authentication and current user,
|
||||
tenant, table, process and record permissions apply. Device calls additionally
|
||||
send `X-QQQ-Device-Id` and `X-QQQ-Device-Token`; that secondary proof does not
|
||||
@@ -51,7 +52,7 @@ a missing capability or 404 is a failure, never permission to bypass saved state
|
||||
Native printing extension `1.0.0` includes these controls; a native-route 404 is
|
||||
always a configuration or compatibility failure, never legacy discovery.
|
||||
|
||||
The new poll reports `controlVersion:1`, `agentVersion`, `capabilities`, and
|
||||
The historical compatibility poll reports `controlVersion:1`, `agentVersion`, `capabilities`, and
|
||||
`lastAppliedRevision`. The two capability names are `REFRESH_INVENTORY` and
|
||||
`CANCEL_SPOOL`; an empty list is valid for a state-only agent. A successful poll
|
||||
establishes state-control support independently of these command capabilities.
|
||||
@@ -73,19 +74,20 @@ and `CONNECTED`. Changes are committed atomically with their action record and
|
||||
advance the revision. Revisions are nonnegative JSON-safe integers, at most
|
||||
9007199254740991. A device reports a revision only after applying and durably
|
||||
retaining that state; it cannot acknowledge a future server revision. The agent
|
||||
must not replace newer persisted state with an older poll response.
|
||||
must not replace newer persisted state with an older received revision.
|
||||
|
||||
The control loop runs independently of printing. Pausing or disconnecting gates
|
||||
new claims and dispatch. It does not cancel CLAIMED or later work, erase local
|
||||
journals, withdraw exposure selections, or discard in-flight job events. Claims
|
||||
and server state changes are serialized so work claimed before the change remains
|
||||
in flight. Before first synchronization or after restart, the agent retains the
|
||||
last state and withholds new claims until valid state or explicit legacy support
|
||||
has been established.
|
||||
last state and withholds new claims until authenticated realtime synchronization
|
||||
has completed. Historical clients instead used explicit legacy support discovery.
|
||||
|
||||
`DISCONNECTED` stops ordinary heartbeat, inventory publication, and new claims.
|
||||
Authenticated control polling, command receipts, and in-flight job events remain
|
||||
available, so `RECONNECT` can reach the device. `REFRESH_INVENTORY` waits for
|
||||
The authenticated socket, command receipts, and in-flight job events remain
|
||||
available, so `RECONNECT` can reach the device; historical clients used control
|
||||
polling for this purpose. `REFRESH_INVENTORY` waits for
|
||||
`CONNECTED` or expires; it must not report success without publishing inventory.
|
||||
Explicit `CANCEL_SPOOL` remains available over the control path while disconnected.
|
||||
Reconnecting does not clear `dispatchPaused`; resuming does not change connection
|
||||
@@ -150,10 +152,11 @@ history or document retention.
|
||||
|
||||
Remote actions initially return `PENDING`. `DeviceCommand` carries stable
|
||||
`commandId`, `requestId`, `action`, `createdAt`, `expiresAt`, and, for spool cancel,
|
||||
`jobId`. Server-created expiry is 15 minutes after creation. One poll returns at
|
||||
most 20 commands and `serverTime`, `pollIntervalSeconds` (1–300), and the full
|
||||
desired state. Pending commands may be delivered repeatedly until a receipt or
|
||||
expiry; they are not new commands on every poll. Do not deliver expired commands.
|
||||
`jobId`. Server-created expiry is 15 minutes after creation. The realtime server
|
||||
delivers `COMMAND` frames and replays unexpired pending commands after reconnect.
|
||||
The historical poll returns at most 20 commands and `serverTime`,
|
||||
`pollIntervalSeconds` (1–300), and the full desired state. Repeated delivery does
|
||||
not create a new command. Do not deliver expired commands.
|
||||
|
||||
The agent journals command identity and execution state before a side effect.
|
||||
Use the current enrollment/connection scope. It checks expiry before beginning,
|
||||
|
||||
+65
-7
@@ -1,14 +1,72 @@
|
||||
# Consumer integration
|
||||
|
||||
## Stable printing release
|
||||
|
||||
The current primary release is [protocol 1.1.0](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.1.0) (annotated tag `v1.1.0` at source `5005f3c9e27053b6c327bb923695012832c0fdcc`, bundle `qqq-print-protocol-1.1.0.tar.gz` SHA-256 `541f0f3054cbfe9ce16e7dea58fad01b55a7f0e9246ade8f30df1bb826940703`), adding printing extension 2.4.0's `printer-approval-v1` capability and `PRINTERS` frame. It pairs with [QBit 1.1.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.0) and the Windows connector 1.2.1; the 1.0.0 cohort below stays valid for 2.3 peers.
|
||||
|
||||
The primary [protocol 1.0.0 release](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.0.0)
|
||||
is published on Gitea (release record 1344). This is the specification and fixture
|
||||
bundle, not an application installer.
|
||||
|
||||
| Component | Stable cohort |
|
||||
| --- | --- |
|
||||
| Protocol distribution | [1.0.0](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.0.0) |
|
||||
| Windows Print Agent | [1.0.1](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1) |
|
||||
| Printing QBit | [1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0) |
|
||||
| QQQ dependency baseline | 4.1.0-RC.21; printing targets QQQ 4.1+ |
|
||||
| Native printing semantic API | 2.3.0 |
|
||||
| Wire payload / WebSocket subprotocol | 1 / `qqq-printing.v1` |
|
||||
|
||||
Upgrade the server before the matching Windows connector. The connector opens an
|
||||
authenticated two-way WebSocket from the facility to QQQ. Job notifications,
|
||||
controls and receipts use that channel; documents stream separately over
|
||||
same-origin HTTPS. Reconnection is automatic and preserves durable job/receipt
|
||||
identity. There is no job/control polling fallback. Standard QQQ authentication
|
||||
and current-user permissions apply throughout.
|
||||
|
||||
### Source and fixtures
|
||||
|
||||
The annotated `v1.0.0` tag resolves to protocol source commit
|
||||
`d652fba1317aec7f25c5a464bac15e97d6094e67`. The release changes distribution
|
||||
versioning, not the wire schema.
|
||||
|
||||
The existing consumer fixture pin is
|
||||
`42c0d249f81395f00408ec0f92638dcbbf5deb03`. This reviewed immutable fixture source
|
||||
is distinct from the stable release source commit. Retain the pin until a reviewed
|
||||
consumer change updates it; do not substitute a moving branch or imply the two
|
||||
commits are identical. The readable GitHub shadow retains that immutable commit
|
||||
for the Windows fixture bridge.
|
||||
|
||||
### Publication and verification scope
|
||||
|
||||
Use [Gitea](https://git.kof22.com/QRun-IO/qqq-print-protocol) for development, pull
|
||||
requests, CI and releases. Protocol GitHub Actions were retired on 2026-11-04 at
|
||||
01:11 UTC; the shadow remains readable. Historical workflow files, compatibility
|
||||
decisions and test evidence are retained as history. Their presence is not an
|
||||
active GitHub publishing path.
|
||||
|
||||
Download the release archive and `SHA256SUMS` from the primary release and verify
|
||||
the archive before extracting it. No npm registry publication is performed.
|
||||
This documentation closeout introduces no new build, hosted attestation or
|
||||
hardware-acceptance claim. Schema fixtures establish message shape; installation,
|
||||
authentication deployment and physical output evidence belong to the consumer
|
||||
repositories. See [authentication](QQQ-AUTHENTICATION.md),
|
||||
[realtime transport](REALTIME.md), and [media selection](PRINTER-MEDIA.md).
|
||||
|
||||
## Consumer checkout
|
||||
|
||||
The coordinated repositories are:
|
||||
|
||||
- [qbit-printing](https://github.com/QRun-IO/qbit-printing): QQQ 4.1+ server metadata, durable queue and HTTP endpoints.
|
||||
- [qqq-print-agent](https://github.com/QRun-IO/qqq-print-agent): Windows AppKit companion, service and printer adapters.
|
||||
- [qqq-print-protocol](https://github.com/QRun-IO/qqq-print-protocol): this specification and compatibility fixtures.
|
||||
- [qbit-printing](https://git.kof22.com/QRun-IO/qbit-printing): QQQ 4.1+ server metadata, durable queue, native API and realtime WebSocket endpoint.
|
||||
- [qqq-print-agent](https://git.kof22.com/QRun-IO/qqq-print-agent): Windows AppKit companion, service and printer adapters.
|
||||
- [qqq-print-protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol): this specification and compatibility fixtures.
|
||||
|
||||
The [stable cohort](INTEGRATION.md#stable-printing-release) is protocol 1.0.0, Windows 1.0.1 and QBit 1.0.0,
|
||||
with native printing API 2.3.0 and QQQ 4.1.0-RC.21 as its dependency baseline.
|
||||
|
||||
For local development, clone them as siblings. The Java HTTP test defaults to `../qqq-print-protocol/fixtures` and accepts `-Dprinting.protocol.fixtures=<directory>`. The Windows tests link shared fixtures from the sibling repository. See each consumer README for its build and platform requirements.
|
||||
|
||||
CI must check out this repository at an immutable commit reviewed with the consumer change. A checkout path of `qqq-print-protocol` beside `qbit-printing` or `qqq-print-agent` preserves the local layout. Do not pin a moving branch for release qualification. Maintainers update the pinned commit when adopting a new compatible contract release.
|
||||
CI must check out this repository at an immutable commit reviewed with the consumer change. A checkout path of `qqq-print-protocol` beside `qbit-printing` or `qqq-print-agent` preserves the local layout. Do not pin a moving branch for release qualification. The current consumer fixture pins are `ac835f8b9396b641b525669249fdb4a1e2a50c99` (Windows, the PR 11 commit) and `5005f3c9e27053b6c327bb923695012832c0fdcc` (QBit, the 1.1.0 source); both carry the 1.1.0 fixtures. Maintainers update the pin through a reviewed consumer change; the readable GitHub shadow retains it for the Windows fixture bridge.
|
||||
|
||||
If a consumer builds outside a multi-checkout workspace, download the release archive, verify `SHA256SUMS`, extract it and point its tests at `fixtures/`. Consume `schemas/protocol.schema.json#/$defs/<Type>` with a JSON Schema 2020-12 validator.
|
||||
|
||||
@@ -18,7 +76,7 @@ The coordinated realtime client requires printing semantic extension
|
||||
`>=2.0.0 <3.0.0` and the authenticated `qqq-printing.v1` WebSocket subprotocol.
|
||||
Wire payload version remains 1. [REALTIME.md](REALTIME.md) defines the new frames,
|
||||
configurable policy, streaming limits and runtime acceptance expectations.
|
||||
The release candidate is [0.3.0-rc.1](https://github.com/QRun-IO/qqq-print-protocol/releases/tag/v0.3.0-rc.1).
|
||||
This transport is included in [stable protocol 1.0.0](INTEGRATION.md#stable-printing-release).
|
||||
Upgrade the server before the Windows connector; fixtures do not establish installed behavior.
|
||||
|
||||
Historical HTTP wire protocol v1 is selected by the `/printing/v1` path and enrollment response `protocolVersion: 1`. Published distribution version `0.2.0-rc.1` identifies the specification/fixture bundle; it does not change that wire version. The Windows and QBit release numbers are independent of both. Native clients use the discovered printing extension semantic version; the realtime contract above adds its explicit version/capability gate without redefining core discovery.
|
||||
@@ -37,11 +95,11 @@ Claim acknowledgment means local receipt; SPOOLED means Windows accepted the doc
|
||||
## Checks and artifacts
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm ci --ignore-scripts
|
||||
npm run check
|
||||
npm run bundle
|
||||
```
|
||||
|
||||
The check runs full draft-2020-12 schema compilation and fixture validation with Ajv, plus independent content/hash/base64 and invalid-input checks. `node tools/verify-fixtures.mjs` remains available without installing dependencies for a quick subset check.
|
||||
|
||||
The bundle includes specification, schema, fixtures, tools, locked validator dependencies and license metadata, excluding installed dependencies. Consumers do not need Node.js to use the schema or fixture JSON. Maintainers publish the bundle as a GitHub release asset; no npm registry publication is performed.
|
||||
The bundle includes specification, schema, fixtures, tools, dependency manifests and lockfile, and license metadata, excluding installed dependencies. Consumers do not need Node.js to use the schema or fixture JSON. Gitea CI publishes the bundle and checksum on [Gitea releases](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases) from a `v<version>` tag; no npm registry publication is performed and there is no artifact attestation (Gitea has no equivalent). Protocol GitHub Actions were retired on 2026-11-04; retained files and historical results do not imply a new hosted build or attestation.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Printer media contract
|
||||
|
||||
This additive contract is included in [protocol 1.0.0, Windows 1.0.1 and QBit 1.0.0](INTEGRATION.md#stable-printing-release).
|
||||
Older 0.3.2 clients do not advertise it. Existing wire version 1 and the
|
||||
WebSocket subprotocol remain unchanged. The feature minimum is printing extension
|
||||
2.3.0 (see [VERSIONS.md](VERSIONS.md)). A client must offer `printer-media-v1` only
|
||||
after discovering a stable 2.3+ extension within major two, so a 2.2 (rendering)
|
||||
server keeps it a three-capability peer. Older 2.x deployments do not promise the
|
||||
operator routes.
|
||||
|
||||
A client advertising `printer-media-v1` reports optional `mediaProfiles` in each
|
||||
exposed printer's inventory. The initial implementation reports at most one
|
||||
configured machine-wide Windows queue default. An absent or empty list means
|
||||
that qualified dimensions and resolution are unavailable; callers must not guess.
|
||||
|
||||
Each profile contains `id`, `revision`, `name`, `widthMicrons`, `heightMicrons`,
|
||||
`dpiX`, `dpiY`, `source: WINDOWS_QUEUE_DEFAULT` and `applyMode: DRIVER_DEFAULT`.
|
||||
Dimensions are positive integer microns, at most 2,000,000. Resolution values are
|
||||
positive integer dots per inch, at most 9,600. The display name has at most 512
|
||||
characters. Inventory permits at most 32 profiles per printer.
|
||||
|
||||
The ID is opaque and stable for that queue default. The revision is a lowercase
|
||||
64-character SHA-256 fingerprint of the effective configuration, including driver
|
||||
identity and relevant default settings. It is a concurrency binding, not an
|
||||
authentication credential. Neither value should be reconstructed by a consumer.
|
||||
Profile IDs use the existing 1–128-character URL-safe ID contract. Catalog device
|
||||
names allow up to 1,024 characters, including legacy computer-name fallbacks;
|
||||
screens wrap these labels without changing their selection values.
|
||||
|
||||
Submit the selected `options.mediaProfile: {id, revision}` verbatim. Combining it
|
||||
with `paperWidthMm` or `paperHeightMm` is invalid. The server validates the exact
|
||||
currently authorized destination and advertised selection and includes both
|
||||
values in the immutable acceptance/idempotency binding. The client rechecks the
|
||||
profile before native spool submission and refuses stale or unavailable profiles.
|
||||
The server also requires the currently connected client to advertise
|
||||
`printer-media-v1` before claiming a selected-profile job. Capability replacement
|
||||
on reconnection must remove stale support; old clients cannot safely ignore the
|
||||
selection. Adding this value to historical control-capability DTOs preserves their
|
||||
schema compatibility and does not introduce a polling transport.
|
||||
|
||||
`DRIVER_DEFAULT` does not change stock or Windows defaults. Reported dimensions
|
||||
describe configured defaults, not a physical stock sensor. Renderer fit, barcode
|
||||
encoding and quiet zones belong to Document Generation. PDF page dimensions
|
||||
alone do not configure the printer.
|
||||
|
||||
The operator catalog and submission use normal QQQ current-user, tenant and row
|
||||
authorization. Operator access does not grant device management or queue control.
|
||||
Retained-source submission must atomically bind the exact source version, digest,
|
||||
length, initiating actor, request and destination with the durable acceptance
|
||||
receipt. Acceptance means a committed queue entry; it does not prove paper output.
|
||||
Lost responses reconcile the same request key rather than issuing a new request.
|
||||
|
||||
The native routes below are relative to the discovered printing extension
|
||||
path, behind the standard QQQ authentication and authorization layers:
|
||||
|
||||
| Operation | Contract |
|
||||
| --- | --- |
|
||||
| `GET /operator/printers` | `OperatorPrinterList`; `limit` is 1–200 (default 50), `after` is the opaque returned cursor. |
|
||||
| `POST /operator/jobs` | `OperatorSubmitJobRequest` to committed `PrintJob`. |
|
||||
| `GET /operator/requests/{requestId}` | The same actor/tenant's current authorized `PrintJob`, or 404. |
|
||||
|
||||
Catalog entries include native `printerRowId` and device identity, effective
|
||||
`name` and `deviceName`, status, formats and profiles. IDs are selection values;
|
||||
screens display the names. The catalog excludes disabled, unapproved, unexposed
|
||||
and unauthorized destinations. The continuation cursor follows stable row order;
|
||||
clients continue until `nextCursor` is null, including after an empty filtered page.
|
||||
|
||||
Submission provides `requestId`, `printerRowId`, `title`, `format`,
|
||||
`contentBase64` and optional `options`. The request ID is 1–128 ASCII letters,
|
||||
digits, dots, underscores, colons or hyphens, so `echo:<scanId>` is supported.
|
||||
The server derives the initiating actor and tenant from current QQQ authority;
|
||||
these cannot be supplied in the request. Existing configured format/document
|
||||
limits apply. Changing destination, content or options under the same request
|
||||
key must conflict, rather than accept another job. Successful reconciliation
|
||||
still requires current destination authority. The retained-source Java facade
|
||||
uses the same queue and authorization boundary; it does not send source bytes
|
||||
through a second queue or replace retained-owner admission policy.
|
||||
|
||||
The synthetic fixture uses 4 by 6 inches at 300 DPI only as schema test data. It
|
||||
does not report the resolution or current configuration of any real printer.
|
||||
@@ -1,10 +1,11 @@
|
||||
# Standard QQQ authentication
|
||||
|
||||
The new Windows client uses the QQQ semantic UI/UX API. Package versions, wire
|
||||
The Windows client uses the QQQ semantic UI/UX API. Package versions, wire
|
||||
payload version `1` and the printing extension semantic version are separate
|
||||
contracts. The standard-session contract was published in `0.2.0-rc.1`; realtime
|
||||
transport is part of release candidate `0.3.0-rc.1`. This document does not establish an
|
||||
accepted Authentik deployment.
|
||||
contracts. The standard-session contract was introduced in `0.2.0-rc.1` and
|
||||
realtime transport in `0.3.0-rc.1`; both are included in the [stable cohort](INTEGRATION.md#stable-printing-release).
|
||||
Provider configuration and server session policy still determine the supported
|
||||
sign-in and renewal behavior of each deployment.
|
||||
|
||||
## Discovery and sign-in
|
||||
|
||||
@@ -36,11 +37,11 @@ Missing native grants require server configuration, not a fallback local login.
|
||||
|
||||
The desktop callback must match the shared grant and installer registration.
|
||||
Windows uses the exact private callback
|
||||
`io.qrun.qqq-print-agent:/oauth/callback`; current central candidate policy rejects
|
||||
`io.qrun.qqq-print-agent:/oauth/callback`; the shared native grant rejects
|
||||
HTTP callbacks. The installer registers the callback to a non-elevated forwarding
|
||||
helper, which delivers it only to an active sign-in attempt in the same Windows
|
||||
user/session. State and PKCE validation remain mandatory. The implementation,
|
||||
shared grant and provider deployment must be qualified before Authentik acceptance.
|
||||
shared grant and provider configuration must agree for Authentik sign-in to work.
|
||||
|
||||
## User and device authority
|
||||
|
||||
|
||||
+27
-3
@@ -1,6 +1,7 @@
|
||||
# Realtime printing — semantic extension 2.0
|
||||
|
||||
This is the contract for the `0.3.0-rc.1` realtime release candidate.
|
||||
This transport was introduced in `0.3.0-rc.1` and is included in the
|
||||
[stable 1.0.0 contract](INTEGRATION.md#stable-printing-release), whose paired server advertises printing 2.3.0.
|
||||
Contract publication does not establish installed acceptance. Existing job states, command outcomes,
|
||||
current-user authorization and durable idempotency rules remain authoritative.
|
||||
|
||||
@@ -8,7 +9,7 @@ Native printing extension **2.0.0** makes an authenticated synchronized WebSocke
|
||||
a prerequisite for claiming jobs. Published extension 1.0 clients could claim
|
||||
over HTTP alone, so this changes existing behavior and requires a major version
|
||||
under [Semantic Versioning](https://semver.org/#spec-item-8). A coordinated upgrade
|
||||
does not make that change backward compatible. Product `0.3.0-rc.1`, framework
|
||||
does not make that change backward compatible. Product releases, framework
|
||||
`/qqq/v1`, wire payload v1 and subprotocol `qqq-printing.v1` remain independent.
|
||||
|
||||
## Discovery and connection
|
||||
@@ -31,7 +32,9 @@ The client opens one persistent WebSocket to the configured QQQ origin:
|
||||
`X-QQQ-Device-Id`, `X-QQQ-Device-Token`, `X-QQQ-Client: api/qqq-print-agent`
|
||||
and the canonical same-origin `Origin`.
|
||||
- Capability: `realtime-v1` is mandatory in `HELLO`. Optional command capabilities
|
||||
are the existing `REFRESH_INVENTORY` and `CANCEL_SPOOL`.
|
||||
are the existing `REFRESH_INVENTORY` and `CANCEL_SPOOL`; optional feature
|
||||
capabilities are `printer-media-v1` (extension 2.3.0) and `printer-approval-v1`
|
||||
(extension 2.4.0), each gated by [discovery](VERSIONS.md#negotiation-rules).
|
||||
|
||||
Do not add credentials to URLs, subprotocols, logs or frame payloads. Refuse
|
||||
cross-origin redirects and arbitrary content URLs. The existing session is
|
||||
@@ -67,6 +70,7 @@ compression that could bypass decoded-size limits are not supported.
|
||||
| Server → client | DESIRED_STATE | Existing `DeviceDesiredState` directly |
|
||||
| Server → client | SETTINGS | `RealtimeSettings` directly |
|
||||
| Server → client | JOB_AVAILABLE | Empty object; a coalescible wake-up, not a job or claim |
|
||||
| Server → client | PRINTERS | `printers`: complete `printerId`/`enabled` approval of the latest accepted inventory; only after `printer-approval-v1` |
|
||||
| Server → client | COMMAND | Existing `DeviceCommand` directly |
|
||||
| Client → server | JOB_EVENT | `jobId`, existing `JobEvent` as `event` |
|
||||
| Client → server | COMMAND_RECEIPT | `commandId`, existing `DeviceCommandReceipt` as `receipt` |
|
||||
@@ -96,6 +100,26 @@ refreshing inventory or querying jobs/controls. Only one serialized writer owns
|
||||
socket sends; the receive/control path must not await document download,
|
||||
rendering, printer submission or UI work.
|
||||
|
||||
### Printer approval
|
||||
|
||||
Extension 2.4.0 adds `printer-approval-v1`. A client advertises it only after
|
||||
discovering a stable 2.4+ extension within major two. The server then sends
|
||||
PRINTERS (`RealtimePrinters`) on that connection, through the same serialized
|
||||
writer, after it has processed an accepted INVENTORY from that connection
|
||||
(after that INVENTORY's ACK) and whenever the approval of any printer belonging
|
||||
to the device changes by any server-side path. Nothing else triggers it; there
|
||||
is no timer and the client does not acknowledge it.
|
||||
|
||||
The payload lists every printer in the device's latest accepted inventory, for
|
||||
this device only, as `printerId` plus boolean `enabled`, at most 200 entries.
|
||||
`enabled:false` means the server knows the printer and has not approved it. A
|
||||
printer the device no longer exposes is not listed; an empty inventory yields
|
||||
`{ "printers": [] }`. Each frame replaces the previous one entirely, so frames
|
||||
are idempotent and coalescible like JOB_AVAILABLE. The frame is not a durable
|
||||
event and carries no job or document data. A client that did not advertise the
|
||||
capability never receives it and keeps reading its existing authorized catalog
|
||||
for approval; a server never sends it to a connection that did not advertise it.
|
||||
|
||||
## Ordering, synchronization and replay
|
||||
|
||||
1. Authenticate the connection, then require HELLO before other application work.
|
||||
|
||||
+56
-1
@@ -1,4 +1,59 @@
|
||||
# Session state — 2026-10-31
|
||||
# Session state — 2026-11-04
|
||||
|
||||
## Current stable release
|
||||
|
||||
Protocol [1.0.0](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.0.0)
|
||||
is published on primary Gitea (release 1344), from tagged source
|
||||
`d652fba1317aec7f25c5a464bac15e97d6094e67`. It pairs with Windows 1.0.1 and QBit 1.0.0,
|
||||
native printing API 2.3.0, wire v1 and QQQ dependency baseline 4.1.0-RC.21.
|
||||
Consumer fixtures remain pinned to `42c0d249f81395f00408ec0f92638dcbbf5deb03`,
|
||||
a separate reviewed source commit. See [release details](INTEGRATION.md#stable-printing-release).
|
||||
|
||||
The current transport is client-initiated authenticated two-way WebSocket with
|
||||
independent streamed HTTPS documents and automatic reconnect; no polling fallback.
|
||||
Protocol GitHub Actions were retired at 2026-11-04 01:11 UTC. The readable shadow
|
||||
and immutable fixture commit remain for the Windows fixture bridge.
|
||||
|
||||
This documentation-only closeout updates release guidance, primary links,
|
||||
authentication/media wording and historical status labels. No schema, fixture,
|
||||
runtime or workflow changed; no new build, hosted attestation or hardware test is
|
||||
claimed. Earlier checkpoint statements below retain their original dates and
|
||||
statuses and are not the current backlog.
|
||||
|
||||
## Historical checkpoints
|
||||
|
||||
### Release-cohort integration — 2026-11-01 (branch `release/printing-cohort-20261101`)
|
||||
|
||||
Cohort: protocol package `1.0.0` (source prepared, not yet published; `0.4.0-rc.1` is the
|
||||
latest published), wire v1, native printing extension 2.3.0. This branch integrates the script-quality tooling (locked Ruff/Pyright,
|
||||
ESLint/Stylistic, strict `checkJs`, the `uri-js` license guard) with the short-label
|
||||
test profiles. Short labels add `LABEL_4X2_*` and `LABEL_4X1_*` (203/300/600 dpi)
|
||||
for ZPL and PDF, a `device-test-profile-4x1.request.json` fixture, and explicit
|
||||
actual-size job paper (101.6 × 50.8 or 25.4 mm) for the 4 × 2 and 4 × 1 PDFs, which
|
||||
is never combined with `mediaProfile`. The strictly typed tools keep every existing
|
||||
invalid-input check and add the short-label positive, unsupported-height and
|
||||
job-paper assertions. Expected totals are 76 schema definitions and 79 fixtures.
|
||||
|
||||
State: implemented and locally verified on the merged tree. `npm run check` passed
|
||||
76 schema definitions and 79 fixtures, and `npm run typecheck`, `npm run lint` and
|
||||
`npm run bundle` all exited 0. Hosted Gitea CI, consumer pin updates, installed-client
|
||||
acceptance and physical output are pending; nothing here claims them. The consumer
|
||||
repositories pin this protocol by immutable commit after the final protocol commit
|
||||
exists.
|
||||
|
||||
## Echo printer media contract in progress (earlier checkpoint)
|
||||
|
||||
Isolated branch `feat/echo-media-contract-20261101` adds configured Windows
|
||||
queue-default profiles and an exact ID/revision job selection. See
|
||||
[Printer media contract](PRINTER-MEDIA.md). Strict schema checks pass 76
|
||||
definitions and 78 synthetic fixtures, including invalid media and conflicting
|
||||
dimension options. These tests establish the draft wire shape only.
|
||||
|
||||
Matching Windows discovery/revalidation, least-privilege operator catalog and
|
||||
submission, and atomic retained-source adoption are active in separate consumer
|
||||
branches. The server source targets extension 2.3.0 (2.2.0 is rendering on protected main); no compatible published
|
||||
consumer tuple is qualified yet. The installed 0.3.2 client does not advertise
|
||||
this capability. No physical output was submitted.
|
||||
|
||||
## Realtime contract implementation
|
||||
|
||||
|
||||
+39
-2
@@ -1,6 +1,43 @@
|
||||
# TODO
|
||||
|
||||
## Realtime 0.3.0-rc.1 release candidate
|
||||
## Current protocol closeout — 2026-11-04
|
||||
|
||||
- [x] Publish stable protocol 1.0.0 on primary Gitea (release 1344).
|
||||
- [x] Document the Windows 1.0.1 / QBit 1.0.0 cohort, native API 2.3.0, wire v1,
|
||||
current realtime/authentication/media behavior and separate immutable fixture pin.
|
||||
- [x] Retire protocol GitHub Actions; retain the readable shadow and Windows fixture bridge.
|
||||
|
||||
No protocol implementation or publication item remains for this stable release.
|
||||
Consumer installation and hardware evidence remain in their repositories.
|
||||
Documentation review and publication are coordinated by the release owner.
|
||||
|
||||
## Historical task ledger
|
||||
|
||||
The original checkpoint statuses below are preserved as history, not as open
|
||||
tasks for the published stable release. See [current release details](INTEGRATION.md#stable-printing-release).
|
||||
|
||||
### Script quality gates
|
||||
|
||||
- [x] Pin Ruff, strict Pyright, ESLint/Stylistic and strict checkJs with locked restores; reject untracked-scope Python/JavaScript and zero-file Pyright runs; negative tool probes pass locally.
|
||||
- [ ] Observe the new `verify` job steps pass on hosted CI before relying on them.
|
||||
|
||||
### Release cohort integration (0.4.0-rc.1, unreleased at this checkpoint)
|
||||
|
||||
- [x] Integrate short-label (4 × 2 / 4 × 1) test profiles, fixture and job-paper assertions with the strictly typed tools in source.
|
||||
- [x] Pass the merged-tree `npm run check` (76 definitions / 79 fixtures), `npm run typecheck`, `npm run lint` and `npm run bundle`.
|
||||
- [ ] Pin the final protocol commit in both consumers, then observe hosted Gitea CI.
|
||||
- [ ] Complete installed client and physical short-label acceptance separately; schema checks do not establish them.
|
||||
|
||||
### Echo media integration
|
||||
|
||||
- [x] Define bounded queue-default profile and exact revision selection schemas.
|
||||
- [x] Validate positive fixtures and reject invalid/ambiguous options.
|
||||
- [ ] Qualify matching client discovery and stale-profile refusal before spooling.
|
||||
- [ ] Qualify current-user operator catalog, submission and replay authorization.
|
||||
- [ ] Qualify atomic retained-source acceptance and lost-response reconciliation.
|
||||
- [ ] Publish the reviewed immutable contract and compatible consumer tuple.
|
||||
|
||||
### Realtime 0.3.0-rc.1 release candidate
|
||||
|
||||
- [x] Freeze printing extension 2.0, authenticated realtime frames and independent
|
||||
streamed HTTPS documents with no polling fallback.
|
||||
@@ -14,7 +51,7 @@
|
||||
authorization acceptance before release; schema checks do not establish them.
|
||||
- [ ] Complete installed UI and physical output acceptance separately.
|
||||
|
||||
## Historical 0.2 and earlier implementation checkpoints
|
||||
### Historical 0.2 and earlier implementation checkpoints
|
||||
|
||||
The entries below retain their original checkpoint status. They do not describe
|
||||
the current realtime release's completion or publication.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Extension version registry
|
||||
|
||||
Distribution, native API, wire and subprotocol versions are independent. Do not
|
||||
compare them as if they were one version sequence. See the [stable cohort](INTEGRATION.md#stable-printing-release).
|
||||
|
||||
| Number | Meaning | Current |
|
||||
| --- | --- | --- |
|
||||
| Protocol distribution | Version of the specification and fixture archive | 1.1.0 (unreleased; 1.0.0 published) |
|
||||
| Wire payload version | Shape of the JSON messages in this repository | 1 |
|
||||
| WebSocket subprotocol | Framing and message envelope | `qqq-printing.v1` |
|
||||
| Printing semantic extension | Server feature level discovered at `/qqq/v1/version` | see below |
|
||||
|
||||
## Printing semantic extension
|
||||
|
||||
Extension versions are discovered, never assumed. Within major 2 each minor is
|
||||
additive and keeps the earlier behavior.
|
||||
|
||||
| Extension | Adds | Contract |
|
||||
| --- | --- | --- |
|
||||
| 2.0.0 | Authenticated realtime channel is required for native claims; no polling fallback | [REALTIME.md](REALTIME.md) |
|
||||
| 2.1.0 | Record, table-list and text print processes | server documentation |
|
||||
| 2.2.0 | Render-to-PDF process, header and footer values, content sections. No media negotiation | server documentation |
|
||||
| 2.3.0 | Least-privilege operator catalog, submission and request lookup; reported queue-default media and `printer-media-v1`; opt-in retained-source facade | [PRINTER-MEDIA.md](PRINTER-MEDIA.md) |
|
||||
| 2.4.0 | `printer-approval-v1` and the server → client `PRINTERS` frame reporting each exposed printer's approval | [REALTIME.md](REALTIME.md#printer-approval) |
|
||||
|
||||
## Negotiation rules
|
||||
|
||||
- A client advertises a capability only when it supports it **and** the discovered
|
||||
extension is a stable `major.minor.patch` version in major 2 at or above the
|
||||
feature minimum. `printer-media-v1` has minimum 2.3.0; `printer-approval-v1`
|
||||
has minimum 2.4.0.
|
||||
- A client facing 2.0, 2.1 or 2.2 stays a three-capability peer (`realtime-v1`,
|
||||
`REFRESH_INVENTORY`, `CANCEL_SPOOL`) and sends no media inventory. A client
|
||||
facing 2.3 is at most a four-capability peer and receives no `PRINTERS` frame.
|
||||
- A server sends `PRINTERS` only on a connection whose HELLO advertised
|
||||
`printer-approval-v1`, whatever extension version it serves; a client that did
|
||||
not advertise it keeps its own approval fallback.
|
||||
- A server must not let a client that did not advertise `printer-media-v1` claim a
|
||||
job that carries a media selection, whatever extension version that client
|
||||
discovered.
|
||||
- Capabilities are re-evaluated on each new socket, so a server upgrade from 2.0
|
||||
to 2.4, or a downgrade from 2.4 to 2.2, takes effect at the next reconnect
|
||||
without a polling schedule.
|
||||
- Pre-release, ambiguous or malformed versions (`2.3.0-rc.1`, `2.3`, `02.3.0`,
|
||||
four-part values) never enable a feature.
|
||||
|
||||
QBit 1.0.0 advertises native printing extension 2.3.0. Windows 1.0.1 is the paired
|
||||
stable client; their product versions do not replace capability discovery.
|
||||
QBit 1.1.0 advertises 2.4.0 and pairs with Windows 1.2.1; a 2.4.0 server still
|
||||
serves 1.0.1/1.1.x clients as four-capability peers, and a 1.2.1 client facing
|
||||
2.3.0 does not advertise `printer-approval-v1`.
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Based on K22 fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152.
|
||||
// KofTwentyTwo TypeScript / JavaScript lint configuration (K22-CODE-TS).
|
||||
// The Kingsrook layout (3-space indent, Allman braces, if(...) with no space, no more than
|
||||
// one blank line inside code) is enforced by ESLint Stylistic rather than Prettier, which
|
||||
// cannot express Allman braces. Type-aware strict rules come from typescript-eslint.
|
||||
// Install: npm i -D eslint @eslint/js typescript-eslint @stylistic/eslint-plugin typescript
|
||||
import js from "@eslint/js";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["**", "!tools/", "!tools/**", "!eslint.config.mjs"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
tseslint.configs.strictTypeChecked,
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { process: "readonly", console: "readonly", Buffer: "readonly", URL: "readonly", fetch: "readonly", WebSocket: "readonly", setTimeout: "readonly", clearTimeout: "readonly", structuredClone: "readonly", performance: "readonly", AbortController: "readonly", Headers: "readonly" },
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: { "@stylistic": stylistic },
|
||||
rules: {
|
||||
// Kingsrook layout
|
||||
"@stylistic/indent": ["error", 3, { SwitchCase: 1 }],
|
||||
"@stylistic/brace-style": ["error", "allman", { allowSingleLine: false }],
|
||||
"@stylistic/keyword-spacing": ["error", {
|
||||
before: true,
|
||||
after: true,
|
||||
overrides: {
|
||||
if: { after: false },
|
||||
for: { after: false },
|
||||
while: { after: false },
|
||||
switch: { after: false },
|
||||
catch: { after: false },
|
||||
},
|
||||
}],
|
||||
// 1 blank line inside code; up to 3 so methods can be separated by 3.
|
||||
"@stylistic/no-multiple-empty-lines": ["error", { max: 3, maxBOF: 0, maxEOF: 0 }],
|
||||
"@stylistic/lines-between-class-members": ["error", "always", { exceptAfterSingleLine: true }],
|
||||
"@stylistic/semi": ["error", "always"],
|
||||
"@stylistic/quotes": ["error", "double", { avoidEscape: true }],
|
||||
"@stylistic/comma-dangle": ["error", "always-multiline"],
|
||||
"@stylistic/eol-last": ["error", "always"],
|
||||
"@stylistic/no-tabs": "error",
|
||||
"@stylistic/no-trailing-spaces": "error",
|
||||
"@stylistic/operator-linebreak": ["error", "before"],
|
||||
"@stylistic/dot-location": ["error", "property"],
|
||||
"@stylistic/max-len": "off",
|
||||
|
||||
// Kingsrook non-layout rules
|
||||
"curly": ["error", "all"],
|
||||
"eqeqeq": ["error", "always"],
|
||||
"no-console": "error",
|
||||
"no-warning-comments": ["error", { terms: ["todo", "fixme"], location: "start" }],
|
||||
"@typescript-eslint/explicit-function-return-type": "error",
|
||||
"@typescript-eslint/naming-convention": ["error",
|
||||
{ selector: "default", format: ["camelCase"] },
|
||||
{ selector: "variable", modifiers: ["const", "global"], format: ["camelCase", "UPPER_CASE"] },
|
||||
{ selector: "typeLike", format: ["PascalCase"] },
|
||||
{ selector: "enumMember", format: ["PascalCase", "UPPER_CASE"] },
|
||||
{ selector: "import", format: null },
|
||||
{ selector: "objectLiteralProperty", format: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.js", "**/*.mjs", "**/*.cjs"],
|
||||
extends: [tseslint.configs.disableTypeChecked],
|
||||
// Checked JavaScript uses JSDoc plus strict tsc; TS-only return syntax is unavailable.
|
||||
rules: { "@typescript-eslint/explicit-function-return-type": "off" },
|
||||
},
|
||||
{
|
||||
files: ["tools/**/*.mjs"],
|
||||
// These command-line tools intentionally print their results and diagnostics.
|
||||
rules: { "no-console": ["error", { allow: ["log", "error"] }] },
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
{"printerId":"printer-zebra","format":"ZPL","requestId":"operator-branded-test-4x1","testProfile":"LABEL_4X1_300"}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"copies": 1,
|
||||
"scale": "ACTUAL_SIZE",
|
||||
"mediaProfile": {
|
||||
"id": "media-default",
|
||||
"revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"printers": [
|
||||
{
|
||||
"printerRowId": "printer-row-example",
|
||||
"deviceId": "device-example",
|
||||
"printerId": "queue-example",
|
||||
"name": "Packing labels",
|
||||
"deviceName": "Warehouse print station",
|
||||
"status": "READY",
|
||||
"formats": [
|
||||
"ZPL",
|
||||
"PDF"
|
||||
],
|
||||
"mediaProfiles": [
|
||||
{
|
||||
"id": "media-default",
|
||||
"revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"name": "Configured 4 x 6 inches",
|
||||
"widthMicrons": 101600,
|
||||
"heightMicrons": 152400,
|
||||
"dpiX": 300,
|
||||
"dpiY": 300,
|
||||
"source": "WINDOWS_QUEUE_DEFAULT",
|
||||
"applyMode": "DRIVER_DEFAULT"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"nextCursor": null
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"requestId": "echo:scan-example",
|
||||
"printerRowId": "printer-row-example",
|
||||
"title": "Echo sample",
|
||||
"format": "ZPL",
|
||||
"contentBase64": "XlhBXkZPMjAsMjBeRkRURVNUXkZTXlha",
|
||||
"options": {
|
||||
"copies": 1,
|
||||
"mediaProfile": {
|
||||
"id": "media-default",
|
||||
"revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "media-default",
|
||||
"revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"name": "Configured 4 x 6 inches",
|
||||
"widthMicrons": 101600,
|
||||
"heightMicrons": 152400,
|
||||
"dpiX": 300,
|
||||
"dpiY": 300,
|
||||
"source": "WINDOWS_QUEUE_DEFAULT",
|
||||
"applyMode": "DRIVER_DEFAULT"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "printing",
|
||||
"version": "2.4.0",
|
||||
"pathPrefix": "/qbit/printing"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"type": "HELLO",
|
||||
"messageId": "hello-connection-1",
|
||||
"payload": {
|
||||
"agentVersion": "1.2.0",
|
||||
"capabilities": [
|
||||
"realtime-v1",
|
||||
"REFRESH_INVENTORY",
|
||||
"CANCEL_SPOOL",
|
||||
"printer-media-v1",
|
||||
"printer-approval-v1"
|
||||
],
|
||||
"lastAppliedRevision": 0,
|
||||
"lastSettingsRevision": 0,
|
||||
"limits": {
|
||||
"maxPdfBytes": 536870912,
|
||||
"maxZplBytes": 104857600,
|
||||
"maxFrameBytes": 16777216,
|
||||
"maxQueuedFrames": 4096,
|
||||
"maxQueuedFrameBytes": 67108864,
|
||||
"maxConcurrentDownloads": 16,
|
||||
"maxConcurrentPrints": 16,
|
||||
"maxStagedBytes": 4294967296,
|
||||
"maxCopies": 32767
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"type": "HELLO",
|
||||
"messageId": "hello-connection-1",
|
||||
"payload": {
|
||||
"agentVersion": "0.3.0-rc.1",
|
||||
"capabilities": [
|
||||
"realtime-v1",
|
||||
"REFRESH_INVENTORY",
|
||||
"CANCEL_SPOOL",
|
||||
"printer-media-v1"
|
||||
],
|
||||
"lastAppliedRevision": 0,
|
||||
"lastSettingsRevision": 0,
|
||||
"limits": {
|
||||
"maxPdfBytes": 536870912,
|
||||
"maxZplBytes": 104857600,
|
||||
"maxFrameBytes": 16777216,
|
||||
"maxQueuedFrames": 4096,
|
||||
"maxQueuedFrameBytes": 67108864,
|
||||
"maxConcurrentDownloads": 16,
|
||||
"maxConcurrentPrints": 16,
|
||||
"maxStagedBytes": 4294967296,
|
||||
"maxCopies": 32767
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"type": "PRINTERS",
|
||||
"messageId": "printers-connection-1",
|
||||
"payload": {
|
||||
"printers": [
|
||||
{
|
||||
"printerId": "laser-example",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"printerId": "label-example",
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+1444
-3
File diff suppressed because it is too large.
Load diff
+19
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qrun-io/qqq-print-protocol",
|
||||
"version": "0.3.0-rc.1",
|
||||
"version": "1.1.1-rc.1",
|
||||
"private": true,
|
||||
"description": "Versioned wire contract and compatibility fixtures for QQQ cloud printing.",
|
||||
"license": "Apache-2.0",
|
||||
@@ -10,10 +10,25 @@
|
||||
},
|
||||
"scripts": {
|
||||
"check": "node tools/verify-schema.mjs && node tools/verify-fixtures.mjs",
|
||||
"bundle": "node tools/bundle.mjs"
|
||||
"test": "npm run check",
|
||||
"bundle": "node tools/bundle.mjs",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"quality": "uv sync --locked && uv run --locked --no-sync python .github/scripts/test-script-quality.py && uv run --locked --no-sync python .github/scripts/check-script-quality.py && uv run --locked --no-sync python .github/scripts/test-source-headers.py && uv run --locked --no-sync python .github/scripts/check-source-headers.py"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1"
|
||||
}
|
||||
"ajv-formats": "3.0.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"typescript": "6.0.3",
|
||||
"@stylistic/eslint-plugin": "5.10.0",
|
||||
"@types/node": "24.10.1",
|
||||
"typescript-eslint": "8.72.1",
|
||||
"eslint": "10.14.0",
|
||||
"pyright": "1.1.414"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "11.0.1"
|
||||
},
|
||||
"packageManager": "npm@11.19.1"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
[project]
|
||||
name = "qqq-print-protocol-script-quality"
|
||||
version = "0.0.0"
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["ruff==0.17.3"]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Based on K22 fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152.
|
||||
# KofTwentyTwo Python lint + format configuration (K22-CODE-PY). Copy to the repo root
|
||||
# (or merge into [tool.ruff] in pyproject.toml). `ruff format --check` and `ruff check`
|
||||
# run in CI and pre-commit with zero findings allowed.
|
||||
target-version = "py313"
|
||||
include = [".github/scripts/*.py"]
|
||||
extend-exclude = [".venv", "node_modules", "dist", "target", "bin", "obj"]
|
||||
# Kingsrook: no practical line-length limit (ruff's maximum is 320).
|
||||
line-length = 320
|
||||
# Kingsrook: 3-space indentation. Ruff's formatter supports it fully; pycodestyle's
|
||||
# "multiple of four" rules (E111/E114) are therefore not selected.
|
||||
indent-width = 3
|
||||
|
||||
[format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
line-ending = "lf"
|
||||
docstring-code-format = true
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
"F", # pyflakes
|
||||
"E4", "E7", "E9", "W", # pycodestyle errors that are layout-independent
|
||||
"I", # isort: no wildcard imports, ordered
|
||||
"N", # pep8-naming
|
||||
"UP", # pyupgrade
|
||||
"B", # bugbear
|
||||
"S", # bandit security
|
||||
"BLE", # blind except
|
||||
"T20", # print statements: use logging (Kingsrook logging rule)
|
||||
"ERA", # commented-out code (Kingsrook: no zombie code)
|
||||
"FIX", # FIXME/TODO must be resolved or tracked
|
||||
"TD", # TODO format: author and issue link
|
||||
"PTH", # pathlib over os.path
|
||||
"RET", "SIM", "C4", "PIE", "PL", "RUF",
|
||||
"ANN", # type annotations on every function
|
||||
"D", # docstrings on modules, classes, functions
|
||||
"LOG", "G", # logging hygiene
|
||||
]
|
||||
ignore = [
|
||||
"D203", # conflicts with D211
|
||||
"D213", # conflicts with D212
|
||||
"TD003", # issue link is enforced by review, not regex
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
".github/scripts/check-*.py" = ["T201"] # CLI diagnostics are the public stdout contract, not application logging.
|
||||
".github/scripts/test-*.py" = ["S101", "PLR2004"] # Synthetic assertions and explicit boundary values.
|
||||
|
||||
[lint.pydocstyle]
|
||||
convention = "pep257"
|
||||
|
||||
[lint.flake8-tidy-imports]
|
||||
ban-relative-imports = "all"
|
||||
@@ -65,7 +65,13 @@
|
||||
"enum": [
|
||||
"LABEL_4X6_203",
|
||||
"LABEL_4X6_300",
|
||||
"LABEL_4X6_600"
|
||||
"LABEL_4X6_600",
|
||||
"LABEL_4X2_203",
|
||||
"LABEL_4X2_300",
|
||||
"LABEL_4X2_600",
|
||||
"LABEL_4X1_203",
|
||||
"LABEL_4X1_300",
|
||||
"LABEL_4X1_600"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -79,7 +85,13 @@
|
||||
"A4",
|
||||
"LABEL_4X6_203",
|
||||
"LABEL_4X6_300",
|
||||
"LABEL_4X6_600"
|
||||
"LABEL_4X6_600",
|
||||
"LABEL_4X2_203",
|
||||
"LABEL_4X2_300",
|
||||
"LABEL_4X2_600",
|
||||
"LABEL_4X1_203",
|
||||
"LABEL_4X1_300",
|
||||
"LABEL_4X1_600"
|
||||
]
|
||||
},
|
||||
"DeviceRetryRequest": {
|
||||
@@ -268,6 +280,7 @@
|
||||
"Printer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mediaProfiles": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/PrinterMediaProfile" } },
|
||||
"printerId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
@@ -342,6 +355,7 @@
|
||||
"ManagedPrinter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mediaProfiles": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/PrinterMediaProfile" } },
|
||||
"printerId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
@@ -449,9 +463,170 @@
|
||||
],
|
||||
"additionalProperties": true
|
||||
},
|
||||
"PrintOptions": {
|
||||
"OperatorPrinter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"printerRowId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"deviceId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"printerId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 1024,
|
||||
"minLength": 1
|
||||
},
|
||||
"deviceName": {
|
||||
"type": "string",
|
||||
"maxLength": 1024,
|
||||
"minLength": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 4096
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/$defs/PrinterStatus"
|
||||
},
|
||||
"formats": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Format"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"mediaProfiles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/PrinterMediaProfile"
|
||||
},
|
||||
"maxItems": 32
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"printerRowId",
|
||||
"deviceId",
|
||||
"printerId",
|
||||
"name",
|
||||
"deviceName",
|
||||
"status",
|
||||
"formats",
|
||||
"mediaProfiles"
|
||||
],
|
||||
"additionalProperties": true
|
||||
},
|
||||
"OperatorPrinterList": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"printers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/OperatorPrinter"
|
||||
},
|
||||
"maxItems": 200
|
||||
},
|
||||
"nextCursor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"printers",
|
||||
"nextCursor"
|
||||
],
|
||||
"additionalProperties": true
|
||||
},
|
||||
"OperatorSubmitJobRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9._:-]+$"
|
||||
},
|
||||
"printerRowId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 1024,
|
||||
"minLength": 1
|
||||
},
|
||||
"format": {
|
||||
"$ref": "#/$defs/Format"
|
||||
},
|
||||
"contentBase64": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"contentEncoding": "base64"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/$defs/PrintOptions"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"requestId",
|
||||
"printerRowId",
|
||||
"title",
|
||||
"format",
|
||||
"contentBase64"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PrinterMediaProfile": {
|
||||
"type": "object",
|
||||
"description": "Configured Windows queue defaults; physical stock is not sensed.",
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/Id" },
|
||||
"revision": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 512 },
|
||||
"widthMicrons": { "type": "integer", "minimum": 1, "maximum": 2000000 },
|
||||
"heightMicrons": { "type": "integer", "minimum": 1, "maximum": 2000000 },
|
||||
"dpiX": { "type": "integer", "minimum": 1, "maximum": 9600 },
|
||||
"dpiY": { "type": "integer", "minimum": 1, "maximum": 9600 },
|
||||
"source": { "type": "string", "const": "WINDOWS_QUEUE_DEFAULT" },
|
||||
"applyMode": { "type": "string", "const": "DRIVER_DEFAULT" }
|
||||
},
|
||||
"required": ["id", "revision", "name", "widthMicrons", "heightMicrons", "dpiX", "dpiY", "source", "applyMode"],
|
||||
"additionalProperties": true
|
||||
},
|
||||
"PrinterMediaSelection": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/Id" },
|
||||
"revision": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
|
||||
},
|
||||
"required": ["id", "revision"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PrintOptions": {
|
||||
"not": {
|
||||
"properties": { "mediaProfile": true },
|
||||
"required": ["mediaProfile"],
|
||||
"anyOf": [
|
||||
{ "properties": { "paperWidthMm": true }, "required": ["paperWidthMm"] },
|
||||
{ "properties": { "paperHeightMm": true }, "required": ["paperHeightMm"] }
|
||||
]
|
||||
},
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mediaProfile": { "$ref": "#/$defs/PrinterMediaSelection" },
|
||||
"copies": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
@@ -817,7 +992,9 @@
|
||||
"ControlCapability": {
|
||||
"enum": [
|
||||
"REFRESH_INVENTORY",
|
||||
"CANCEL_SPOOL"
|
||||
"CANCEL_SPOOL",
|
||||
"printer-media-v1",
|
||||
"printer-approval-v1"
|
||||
]
|
||||
},
|
||||
"ProtocolVersionResponse": {
|
||||
@@ -884,7 +1061,7 @@
|
||||
"items": {
|
||||
"$ref": "#/$defs/ControlCapability"
|
||||
},
|
||||
"maxItems": 2,
|
||||
"maxItems": 4,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"lastPollAt": {
|
||||
@@ -923,7 +1100,7 @@
|
||||
"items": {
|
||||
"$ref": "#/$defs/ControlCapability"
|
||||
},
|
||||
"maxItems": 2,
|
||||
"maxItems": 4,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"lastAppliedRevision": {
|
||||
@@ -1541,11 +1718,13 @@
|
||||
"enum": [
|
||||
"realtime-v1",
|
||||
"REFRESH_INVENTORY",
|
||||
"CANCEL_SPOOL"
|
||||
"CANCEL_SPOOL",
|
||||
"printer-media-v1",
|
||||
"printer-approval-v1"
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 3,
|
||||
"maxItems": 5,
|
||||
"uniqueItems": true,
|
||||
"contains": {
|
||||
"const": "realtime-v1"
|
||||
@@ -1891,6 +2070,61 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PrinterApprovalState": {
|
||||
"description": "Server approval of one printer in the device's latest accepted inventory; enabled:false means known but not approved.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"printerId",
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"printerId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"RealtimePrintersPayload": {
|
||||
"description": "Complete approval state of every printer the device currently exposes; each frame replaces the previous one.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"printers"
|
||||
],
|
||||
"properties": {
|
||||
"printers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/PrinterApprovalState"
|
||||
},
|
||||
"maxItems": 200
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"RealtimePrinters": {
|
||||
"description": "Sent only on connections whose HELLO advertised printer-approval-v1; not acknowledged.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "PRINTERS"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/RealtimePrintersPayload"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeCommand": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -2007,6 +2241,9 @@
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeJobAvailable"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimePrinters"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeCommand"
|
||||
},
|
||||
|
||||
+21
-17
@@ -1,25 +1,29 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
assert(/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(manifest.version), 'Invalid release version.');
|
||||
if(process.env.GITHUB_REF_TYPE === 'tag')
|
||||
const root = fileURLToPath(new URL("../", import.meta.url));
|
||||
/** @type {{version:string}} */
|
||||
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||||
assert(/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(manifest.version), "Invalid release version.");
|
||||
if(process.env["GITHUB_REF_TYPE"] === "tag")
|
||||
{
|
||||
assert.equal(process.env.GITHUB_REF_NAME, `v${manifest.version}`, 'Release tag must match package.json.');
|
||||
assert.equal(process.env["GITHUB_REF_NAME"], `v${manifest.version}`, "Release tag must match package.json.");
|
||||
}
|
||||
mkdirSync(new URL('../dist/', import.meta.url), { recursive: true });
|
||||
mkdirSync(new URL("../dist/", import.meta.url), { recursive: true });
|
||||
const name = `qqq-print-protocol-${manifest.version}.tar.gz`;
|
||||
const files = ['README.md', 'LICENSE', 'NOTICE', 'CHANGELOG.md', 'CONTRIBUTING.md', 'SECURITY.md', 'package.json', 'package-lock.json', 'schemas', 'fixtures', 'tools', 'docs/INTEGRATION.md', 'docs/DEVICE-CONTROLS.md', 'docs/QQQ-AUTHENTICATION.md', 'docs/REALTIME.md'];
|
||||
const result = spawnSync('tar', ['-czf', `dist/${name}`, ...files], { cwd: root, stdio: 'inherit', shell: false });
|
||||
if(result.error) throw result.error;
|
||||
assert.equal(result.status, 0, 'Archive creation failed.');
|
||||
const digest = createHash('sha256').update(readFileSync(new URL(`../dist/${name}`, import.meta.url))).digest('hex');
|
||||
writeFileSync(new URL('../dist/SHA256SUMS', import.meta.url), `${digest} ${name}\n`);
|
||||
const files = ["README.md", "LICENSE", "NOTICE", "CHANGELOG.md", "CONTRIBUTING.md", "SECURITY.md", "package.json", "package-lock.json", "tsconfig.json", "eslint.config.mjs", "schemas", "fixtures", "tools", "docs/INTEGRATION.md", "docs/DEVICE-CONTROLS.md", "docs/QQQ-AUTHENTICATION.md", "docs/REALTIME.md", "docs/PRINTER-MEDIA.md"];
|
||||
const result = spawnSync("tar", ["-czf", `dist/${name}`, ...files], { cwd: root, stdio: "inherit", shell: false });
|
||||
if(result.error)
|
||||
{
|
||||
throw result.error;
|
||||
}
|
||||
assert.equal(result.status, 0, "Archive creation failed.");
|
||||
const digest = createHash("sha256").update(readFileSync(new URL(`../dist/${name}`, import.meta.url))).digest("hex");
|
||||
writeFileSync(new URL("../dist/SHA256SUMS", import.meta.url), `${digest} ${name}\n`);
|
||||
console.log(`Created dist/${name} and dist/SHA256SUMS.`);
|
||||
@@ -1,35 +1,35 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
// Keep printable fixtures synthetic and independent of a printer's settings.
|
||||
const content = Buffer.from('^XA\n^FO40,40^A0N,36,36^FDQQQ cloud print test^FS\n^FO40,90^A0N,24,24^FDProtocol v1 - one test label^FS\n^XZ\n', 'utf8');
|
||||
const root = new URL('../fixtures/', import.meta.url);
|
||||
writeFileSync(new URL('test-label.zpl', root), content);
|
||||
const content = Buffer.from("^XA\n^FO40,40^A0N,36,36^FDQQQ cloud print test^FS\n^FO40,90^A0N,24,24^FDProtocol v1 - one test label^FS\n^XZ\n", "utf8");
|
||||
const root = new URL("../fixtures/", import.meta.url);
|
||||
writeFileSync(new URL("test-label.zpl", root), content);
|
||||
|
||||
const job = {
|
||||
jobId: 'job-example',
|
||||
deviceId: 'device-example',
|
||||
printerId: 'zebra-example',
|
||||
title: 'QQQ cloud print test',
|
||||
format: 'ZPL',
|
||||
contentSha256: createHash('sha256').update(content).digest('hex'),
|
||||
jobId: "job-example",
|
||||
deviceId: "device-example",
|
||||
printerId: "zebra-example",
|
||||
title: "QQQ cloud print test",
|
||||
format: "ZPL",
|
||||
contentSha256: createHash("sha256").update(content).digest("hex"),
|
||||
contentLength: content.length,
|
||||
options: { copies: 1, scale: 'ACTUAL_SIZE' },
|
||||
createdAt: '2026-10-29T18:00:00Z',
|
||||
status: 'CLAIMED'
|
||||
options: { copies: 1, scale: "ACTUAL_SIZE" },
|
||||
createdAt: "2026-10-29T18:00:00Z",
|
||||
status: "CLAIMED",
|
||||
};
|
||||
const request = {
|
||||
deviceId: job.deviceId,
|
||||
printerId: job.printerId,
|
||||
title: job.title,
|
||||
format: job.format,
|
||||
contentBase64: content.toString('base64'),
|
||||
idempotencyKey: 'protocol-fixture-label-v1',
|
||||
options: job.options
|
||||
contentBase64: content.toString("base64"),
|
||||
idempotencyKey: "protocol-fixture-label-v1",
|
||||
options: job.options,
|
||||
};
|
||||
writeFileSync(new URL('submit.request.json', root), JSON.stringify(request, null, 2) + '\n');
|
||||
writeFileSync(new URL('claim.response.json', root), JSON.stringify({ job }, null, 2) + '\n');
|
||||
writeFileSync(new URL('job.response.json', root), JSON.stringify(job, null, 2) + '\n');
|
||||
writeFileSync(new URL("submit.request.json", root), JSON.stringify(request, null, 2) + "\n");
|
||||
writeFileSync(new URL("claim.response.json", root), JSON.stringify({ job }, null, 2) + "\n");
|
||||
writeFileSync(new URL("job.response.json", root), JSON.stringify(job, null, 2) + "\n");
|
||||
+82
-73
@@ -3,77 +3,86 @@
|
||||
|
||||
// Shared correspondence between wire examples and schema definitions.
|
||||
export const fixtureTypes = {
|
||||
'printing-realtime-extension.discovery.json': 'PrintingRealtimeExtensionDiscovery',
|
||||
'version-legacy.response.json': 'ProtocolVersionResponse',
|
||||
'version-controls.response.json': 'ProtocolVersionResponse',
|
||||
'control-poll.request.json': 'DeviceControlPollRequest',
|
||||
'control-poll.response.json': 'DeviceControlPollResponse',
|
||||
'control-disconnected.response.json': 'DeviceControlPollResponse',
|
||||
'device-control.response.json': 'Device',
|
||||
'action-pause-dispatch.request.json': 'AdminDeviceActionRequest',
|
||||
'action-resume-dispatch.request.json': 'AdminDeviceActionRequest',
|
||||
'action-disconnect.request.json': 'AdminDeviceActionRequest',
|
||||
'action-reconnect.request.json': 'AdminDeviceActionRequest',
|
||||
'action-revoke.request.json': 'AdminDeviceActionRequest',
|
||||
'action-refresh-inventory.request.json': 'AdminDeviceActionRequest',
|
||||
'action-clear-queue.request.json': 'AdminDeviceActionRequest',
|
||||
'action-cancel-spool.request.json': 'AdminDeviceActionRequest',
|
||||
'action-refresh.response.json': 'DeviceActionRecord',
|
||||
'action-cancel.response.json': 'DeviceActionRecord',
|
||||
'action-clear-queue.response.json': 'DeviceActionRecord',
|
||||
'action-pause-dispatch.response.json': 'DeviceActionRecord',
|
||||
'action-revoke.response.json': 'DeviceActionRecord',
|
||||
'control-inventory-refreshed.receipt.json': 'DeviceCommandReceipt',
|
||||
'control-cancel-requested.receipt.json': 'DeviceCommandReceipt',
|
||||
'control-already-absent.receipt.json': 'DeviceCommandReceipt',
|
||||
'control-ownership-mismatch.receipt.json': 'DeviceCommandReceipt',
|
||||
'control-unsupported.receipt.json': 'DeviceCommandReceipt',
|
||||
'control-failed.receipt.json': 'DeviceCommandReceipt',
|
||||
'control-expired.receipt.json': 'DeviceCommandReceipt',
|
||||
'control-outcome-unknown.receipt.json': 'DeviceCommandReceipt',
|
||||
'control-commands-page.response.json': 'DeviceActionList',
|
||||
'control-cancel-completed.response.json': 'DeviceActionRecord',
|
||||
'heartbeat-profile.request.json': 'HeartbeatRequest',
|
||||
'printers-profile.request.json': 'PrinterInventory',
|
||||
'device-test.request.json': 'DeviceTestPrintRequest',
|
||||
'device-test-profile.request.json': 'DeviceTestPrintRequest',
|
||||
'device-retry.request.json': 'DeviceRetryRequest',
|
||||
'create-enrollment.request.json': 'CreateEnrollmentRequest',
|
||||
'enrollment.response.json': 'EnrollmentCode',
|
||||
'enroll.request.json': 'EnrollRequest',
|
||||
'enroll.response.json': 'EnrollResponse',
|
||||
'heartbeat.request.json': 'HeartbeatRequest',
|
||||
'heartbeat.response.json': 'HeartbeatResponse',
|
||||
'printers.request.json': 'PrinterInventory',
|
||||
'printers-page.response.json': 'PrinterList',
|
||||
'approval.request.json': 'PrinterApproval',
|
||||
'claim-empty.response.json': 'ClaimResponse',
|
||||
'claim.response.json': 'ClaimResponse',
|
||||
'job.response.json': 'PrintJob',
|
||||
'submit.request.json': 'SubmitJobRequest',
|
||||
'received.event.json': 'JobEvent',
|
||||
'submitting.event.json': 'JobEvent',
|
||||
'spooled.event.json': 'JobEvent',
|
||||
'ack.response.json': 'Acknowledgment',
|
||||
'error.response.json': 'Error',
|
||||
'printing-session.response.json': 'PrintingSessionIdentity',
|
||||
'printing-session-bound.response.json': 'PrintingSessionIdentity',
|
||||
'realtime-settings.payload.json': 'RealtimeSettings',
|
||||
'realtime-hello.message.json': 'RealtimeHello',
|
||||
'realtime-welcome.message.json': 'RealtimeWelcome',
|
||||
'realtime-state-applied.message.json': 'RealtimeStateApplied',
|
||||
'realtime-desired-state.message.json': 'RealtimeDesiredState',
|
||||
'realtime-job-available.message.json': 'RealtimeJobAvailable',
|
||||
'realtime-command.message.json': 'RealtimeCommand',
|
||||
'realtime-command-receipt.message.json': 'RealtimeCommandReceipt',
|
||||
'realtime-job-event.message.json': 'RealtimeJobEvent',
|
||||
'realtime-job-event-replay.message.json': 'RealtimeJobEvent',
|
||||
'realtime-settings.message.json': 'RealtimeSettingsMessage',
|
||||
'realtime-ack.message.json': 'RealtimeAck',
|
||||
'realtime-error.message.json': 'RealtimeError',
|
||||
'realtime-heartbeat.message.json': 'RealtimeHeartbeat',
|
||||
'realtime-inventory.message.json': 'RealtimeInventory',
|
||||
'realtime-ping.message.json': 'RealtimePing',
|
||||
'realtime-pong.message.json': 'RealtimePong',
|
||||
'realtime-large-pdf.response.json': 'PrintJob'
|
||||
"printing-realtime-extension.discovery.json": "PrintingRealtimeExtensionDiscovery",
|
||||
"printing-realtime-extension-240.discovery.json": "PrintingRealtimeExtensionDiscovery",
|
||||
"version-legacy.response.json": "ProtocolVersionResponse",
|
||||
"version-controls.response.json": "ProtocolVersionResponse",
|
||||
"control-poll.request.json": "DeviceControlPollRequest",
|
||||
"control-poll.response.json": "DeviceControlPollResponse",
|
||||
"control-disconnected.response.json": "DeviceControlPollResponse",
|
||||
"device-control.response.json": "Device",
|
||||
"action-pause-dispatch.request.json": "AdminDeviceActionRequest",
|
||||
"action-resume-dispatch.request.json": "AdminDeviceActionRequest",
|
||||
"action-disconnect.request.json": "AdminDeviceActionRequest",
|
||||
"action-reconnect.request.json": "AdminDeviceActionRequest",
|
||||
"action-revoke.request.json": "AdminDeviceActionRequest",
|
||||
"action-refresh-inventory.request.json": "AdminDeviceActionRequest",
|
||||
"action-clear-queue.request.json": "AdminDeviceActionRequest",
|
||||
"action-cancel-spool.request.json": "AdminDeviceActionRequest",
|
||||
"action-refresh.response.json": "DeviceActionRecord",
|
||||
"action-cancel.response.json": "DeviceActionRecord",
|
||||
"action-clear-queue.response.json": "DeviceActionRecord",
|
||||
"action-pause-dispatch.response.json": "DeviceActionRecord",
|
||||
"action-revoke.response.json": "DeviceActionRecord",
|
||||
"control-inventory-refreshed.receipt.json": "DeviceCommandReceipt",
|
||||
"control-cancel-requested.receipt.json": "DeviceCommandReceipt",
|
||||
"control-already-absent.receipt.json": "DeviceCommandReceipt",
|
||||
"control-ownership-mismatch.receipt.json": "DeviceCommandReceipt",
|
||||
"control-unsupported.receipt.json": "DeviceCommandReceipt",
|
||||
"control-failed.receipt.json": "DeviceCommandReceipt",
|
||||
"control-expired.receipt.json": "DeviceCommandReceipt",
|
||||
"control-outcome-unknown.receipt.json": "DeviceCommandReceipt",
|
||||
"control-commands-page.response.json": "DeviceActionList",
|
||||
"control-cancel-completed.response.json": "DeviceActionRecord",
|
||||
"heartbeat-profile.request.json": "HeartbeatRequest",
|
||||
"printers-profile.request.json": "PrinterInventory",
|
||||
"device-test.request.json": "DeviceTestPrintRequest",
|
||||
"device-test-profile.request.json": "DeviceTestPrintRequest",
|
||||
"device-test-profile-4x1.request.json": "DeviceTestPrintRequest",
|
||||
"device-retry.request.json": "DeviceRetryRequest",
|
||||
"create-enrollment.request.json": "CreateEnrollmentRequest",
|
||||
"enrollment.response.json": "EnrollmentCode",
|
||||
"enroll.request.json": "EnrollRequest",
|
||||
"enroll.response.json": "EnrollResponse",
|
||||
"heartbeat.request.json": "HeartbeatRequest",
|
||||
"heartbeat.response.json": "HeartbeatResponse",
|
||||
"printers.request.json": "PrinterInventory",
|
||||
"printers-page.response.json": "PrinterList",
|
||||
"approval.request.json": "PrinterApproval",
|
||||
"claim-empty.response.json": "ClaimResponse",
|
||||
"claim.response.json": "ClaimResponse",
|
||||
"job.response.json": "PrintJob",
|
||||
"submit.request.json": "SubmitJobRequest",
|
||||
"received.event.json": "JobEvent",
|
||||
"submitting.event.json": "JobEvent",
|
||||
"spooled.event.json": "JobEvent",
|
||||
"ack.response.json": "Acknowledgment",
|
||||
"error.response.json": "Error",
|
||||
"printing-session.response.json": "PrintingSessionIdentity",
|
||||
"printing-session-bound.response.json": "PrintingSessionIdentity",
|
||||
"realtime-settings.payload.json": "RealtimeSettings",
|
||||
"realtime-hello.message.json": "RealtimeHello",
|
||||
"realtime-welcome.message.json": "RealtimeWelcome",
|
||||
"realtime-state-applied.message.json": "RealtimeStateApplied",
|
||||
"realtime-desired-state.message.json": "RealtimeDesiredState",
|
||||
"realtime-job-available.message.json": "RealtimeJobAvailable",
|
||||
"realtime-command.message.json": "RealtimeCommand",
|
||||
"realtime-command-receipt.message.json": "RealtimeCommandReceipt",
|
||||
"realtime-job-event.message.json": "RealtimeJobEvent",
|
||||
"realtime-job-event-replay.message.json": "RealtimeJobEvent",
|
||||
"realtime-settings.message.json": "RealtimeSettingsMessage",
|
||||
"realtime-ack.message.json": "RealtimeAck",
|
||||
"realtime-error.message.json": "RealtimeError",
|
||||
"realtime-heartbeat.message.json": "RealtimeHeartbeat",
|
||||
"realtime-inventory.message.json": "RealtimeInventory",
|
||||
"realtime-ping.message.json": "RealtimePing",
|
||||
"realtime-pong.message.json": "RealtimePong",
|
||||
"realtime-large-pdf.response.json": "PrintJob",
|
||||
"printer-media-profile.response.json": "PrinterMediaProfile",
|
||||
"media-profile-options.request.json": "PrintOptions",
|
||||
"operator-printers.response.json": "OperatorPrinterList",
|
||||
"operator-submit.request.json": "OperatorSubmitJobRequest",
|
||||
"realtime-media-hello.message.json": "RealtimeHello",
|
||||
"realtime-approval-hello.message.json": "RealtimeHello",
|
||||
"realtime-printers.message.json": "RealtimePrinters",
|
||||
};
|
||||
+191
-68
@@ -1,122 +1,245 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fixtureTypes } from './fixture-types.mjs';
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fixtureTypes } from "./fixture-types.mjs";
|
||||
|
||||
const schema = JSON.parse(readFileSync(new URL('../schemas/protocol.schema.json', import.meta.url), 'utf8'));
|
||||
const root = new URL('../fixtures/', import.meta.url);
|
||||
/** @typedef {{ $ref?:string,not?:Rule|boolean,required?:string[],type?:string,anyOf?:(Rule|boolean)[],const?:unknown,enum?:unknown[],additionalProperties?:boolean,properties?:Record<string,Rule|boolean>,dependentRequired?:Record<string,string[]>,minItems?:number,maxItems?:number,uniqueItems?:boolean,contains?:Rule|boolean,items?:Rule|boolean,minLength?:number,maxLength?:number,pattern?:string,format?:string,contentEncoding?:string,minimum?:number,maximum?:number,exclusiveMinimum?:number }} Rule */
|
||||
/** @type {{$defs:Record<string,Rule>}} */
|
||||
const schema = JSON.parse(readFileSync(new URL("../schemas/protocol.schema.json", import.meta.url), "utf8"));
|
||||
const root = new URL("../fixtures/", import.meta.url);
|
||||
|
||||
// Validate the schema keywords used by this contract without installing dependencies.
|
||||
/** @param {unknown} value @param {Rule|boolean} rule @param {string} path @returns {void} */
|
||||
function validate(value, rule, path)
|
||||
{
|
||||
if(typeof rule === 'boolean')
|
||||
if(typeof rule === "boolean")
|
||||
{
|
||||
assert(rule, `${path}: forbidden property`);
|
||||
return;
|
||||
}
|
||||
if(rule.$ref)
|
||||
{
|
||||
return validate(value, schema.$defs[rule.$ref.split('/').at(-1)], path);
|
||||
return validate(value, /** @type {Rule} */ (schema.$defs[/** @type {string} */ (rule.$ref.split("/").at(-1))]), path);
|
||||
}
|
||||
if(rule.not)
|
||||
{
|
||||
let matches = true;
|
||||
try
|
||||
{
|
||||
validate(value, rule.not, path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
matches = false;
|
||||
}
|
||||
assert(!matches, `${path}: forbidden combination`);
|
||||
}
|
||||
if(rule.required && !rule.type)
|
||||
{
|
||||
for(const name of rule.required)
|
||||
{
|
||||
assert(name in /** @type {object} */ (value), `${path}.${name}: required`);
|
||||
}
|
||||
}
|
||||
if(rule.anyOf)
|
||||
{
|
||||
assert(rule.anyOf.some(candidate =>
|
||||
{
|
||||
try { validate(value, candidate, path); return true; }
|
||||
catch { return false; }
|
||||
try
|
||||
{
|
||||
validate(value, candidate, path); return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}), `${path}: no anyOf variant matched`);
|
||||
}
|
||||
if(rule.const !== undefined) assert.deepEqual(value, rule.const, path);
|
||||
if(rule.enum) assert(rule.enum.includes(value), `${path}: invalid enum`);
|
||||
if(rule.type === 'null') assert.equal(value, null, path);
|
||||
if(rule.type === 'object')
|
||||
if(rule.const !== undefined)
|
||||
{
|
||||
assert(value !== null && typeof value === 'object' && !Array.isArray(value), `${path}: expected object`);
|
||||
for(const name of rule.required ?? []) assert(name in value, `${path}.${name}: required`);
|
||||
assert.deepEqual(value, rule.const, path);
|
||||
}
|
||||
if(rule.enum)
|
||||
{
|
||||
assert(rule.enum.includes(value), `${path}: invalid enum`);
|
||||
}
|
||||
if(rule.type === "null")
|
||||
{
|
||||
assert.equal(value, null, path);
|
||||
}
|
||||
if(rule.type === "object")
|
||||
{
|
||||
assert(value !== null && typeof value === "object" && !Array.isArray(value), `${path}: expected object`);
|
||||
for(const name of rule.required ?? [])
|
||||
{
|
||||
assert(name in /** @type {object} */ (value), `${path}.${name}: required`);
|
||||
}
|
||||
if(rule.additionalProperties === false)
|
||||
{
|
||||
for(const name of Object.keys(value)) assert(name in (rule.properties ?? {}), `${path}.${name}: unknown property`);
|
||||
for(const name of Object.keys(value))
|
||||
{
|
||||
assert(name in (rule.properties ?? {}), `${path}.${name}: unknown property`);
|
||||
}
|
||||
}
|
||||
for(const [name, child] of Object.entries(rule.properties ?? {}))
|
||||
{
|
||||
if(name in value) validate(value[name], child, `${path}.${name}`);
|
||||
if(name in /** @type {object} */ (value))
|
||||
{
|
||||
validate(/** @type {Record<string,unknown>} */ (value)[name], child, `${path}.${name}`);
|
||||
}
|
||||
}
|
||||
for(const [name, dependencies] of Object.entries(rule.dependentRequired ?? {}))
|
||||
{
|
||||
if(name in value) for(const dependency of dependencies) assert(dependency in value, `${path}.${dependency}: required with ${name}`);
|
||||
if(name in /** @type {object} */ (value))
|
||||
{
|
||||
for(const dependency of dependencies)
|
||||
{
|
||||
assert(dependency in value, `${path}.${dependency}: required with ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(rule.type === 'array')
|
||||
if(rule.type === "array")
|
||||
{
|
||||
assert(Array.isArray(value), `${path}: expected array`);
|
||||
if(rule.minItems !== undefined) assert(value.length >= rule.minItems, path);
|
||||
if(rule.maxItems !== undefined) assert(value.length <= rule.maxItems, path);
|
||||
if(rule.uniqueItems) assert.equal(new Set(value.map(item => JSON.stringify(item))).size, value.length, path);
|
||||
if(rule.minItems !== undefined)
|
||||
{
|
||||
assert(value.length >= rule.minItems, path);
|
||||
}
|
||||
if(rule.maxItems !== undefined)
|
||||
{
|
||||
assert(value.length <= rule.maxItems, path);
|
||||
}
|
||||
if(rule.uniqueItems)
|
||||
{
|
||||
assert.equal(new Set(value.map(item => JSON.stringify(item))).size, value.length, path);
|
||||
}
|
||||
if(rule.contains)
|
||||
{
|
||||
assert(value.some(item =>
|
||||
{
|
||||
try { validate(item, rule.contains, path); return true; }
|
||||
catch { return false; }
|
||||
try
|
||||
{
|
||||
validate(item, /** @type {Rule|boolean} */ (rule.contains), path); return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}), `${path}: required array member missing`);
|
||||
}
|
||||
value.forEach((item, index) => validate(item, rule.items, `${path}[${index}]`));
|
||||
value.forEach((item, index) => validate(item, /** @type {Rule|boolean} */ (rule.items), `${path}[${index}]`));
|
||||
}
|
||||
if(rule.type === 'string')
|
||||
if(rule.type === "string")
|
||||
{
|
||||
assert.equal(typeof value, 'string', path);
|
||||
if(rule.minLength !== undefined) assert(value.length >= rule.minLength, path);
|
||||
if(rule.maxLength !== undefined) assert(value.length <= rule.maxLength, path);
|
||||
if(rule.pattern) assert(new RegExp(rule.pattern).test(value), path);
|
||||
if(rule.format === 'date-time') assert(!Number.isNaN(Date.parse(value)) && value.endsWith('Z'), `${path}: UTC date-time required`);
|
||||
if(rule.contentEncoding === 'base64') assert.equal(Buffer.from(value, 'base64').toString('base64'), value, path);
|
||||
assert.equal(typeof value, "string", path);
|
||||
if(rule.minLength !== undefined)
|
||||
{
|
||||
assert((/** @type {string} */ (value)).length >= rule.minLength, path);
|
||||
}
|
||||
if(rule.maxLength !== undefined)
|
||||
{
|
||||
assert((/** @type {string} */ (value)).length <= rule.maxLength, path);
|
||||
}
|
||||
if(rule.pattern)
|
||||
{
|
||||
assert(new RegExp(rule.pattern).test(/** @type {string} */ (value)), path);
|
||||
}
|
||||
if(rule.format === "date-time")
|
||||
{
|
||||
assert(!Number.isNaN(Date.parse(/** @type {string} */ (value))) && (/** @type {string} */ (value)).endsWith("Z"), `${path}: UTC date-time required`);
|
||||
}
|
||||
if(rule.contentEncoding === "base64")
|
||||
{
|
||||
assert.equal(Buffer.from(/** @type {string} */ (value), "base64").toString("base64"), value, path);
|
||||
}
|
||||
}
|
||||
if(rule.type === 'boolean') assert.equal(typeof value, 'boolean', path);
|
||||
if(rule.type === 'integer' || rule.type === 'number')
|
||||
if(rule.type === "boolean")
|
||||
{
|
||||
assert.equal(typeof value, 'number', path);
|
||||
assert.equal(typeof value, "boolean", path);
|
||||
}
|
||||
if(rule.type === "integer" || rule.type === "number")
|
||||
{
|
||||
assert.equal(typeof value, "number", path);
|
||||
assert(Number.isFinite(value), path);
|
||||
if(rule.type === 'integer') assert(Number.isInteger(value), path);
|
||||
if(rule.minimum !== undefined) assert(value >= rule.minimum, path);
|
||||
if(rule.maximum !== undefined) assert(value <= rule.maximum, path);
|
||||
if(rule.exclusiveMinimum !== undefined) assert(value > rule.exclusiveMinimum, path);
|
||||
if(rule.type === "integer")
|
||||
{
|
||||
assert(Number.isInteger(value), path);
|
||||
}
|
||||
if(rule.minimum !== undefined)
|
||||
{
|
||||
assert(/** @type {number} */ (value) >= rule.minimum, path);
|
||||
}
|
||||
if(rule.maximum !== undefined)
|
||||
{
|
||||
assert(/** @type {number} */ (value) <= rule.maximum, path);
|
||||
}
|
||||
if(rule.exclusiveMinimum !== undefined)
|
||||
{
|
||||
assert(/** @type {number} */ (value) > rule.exclusiveMinimum, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Record<string,object>} */
|
||||
const fixtures = {};
|
||||
for(const [name, type] of Object.entries(fixtureTypes))
|
||||
{
|
||||
fixtures[name] = JSON.parse(readFileSync(new URL(name, root), 'utf8'));
|
||||
validate(fixtures[name], schema.$defs[type], name);
|
||||
fixtures[name] = JSON.parse(readFileSync(new URL(name, root), "utf8"));
|
||||
validate(fixtures[name], /** @type {Rule} */ (schema.$defs[type]), name);
|
||||
}
|
||||
const content = readFileSync(new URL('test-label.zpl', root));
|
||||
const job = fixtures['claim.response.json'].job;
|
||||
const content = readFileSync(new URL("test-label.zpl", root));
|
||||
const job = (/** @type {typeof import("../fixtures/claim.response.json")} */ (fixtures["claim.response.json"])).job;
|
||||
assert.equal(content.length, job.contentLength);
|
||||
assert.equal(createHash('sha256').update(content).digest('hex'), job.contentSha256);
|
||||
assert.deepEqual(Buffer.from(fixtures['submit.request.json'].contentBase64, 'base64'), content);
|
||||
assert.deepEqual(fixtures['job.response.json'], job);
|
||||
assert.throws(() => validate({ copies: 0 }, schema.$defs.PrintOptions, 'invalidCopies'));
|
||||
assert.throws(() => validate({ paperWidthMm: 100 }, schema.$defs.PrintOptions, 'missingHeight'));
|
||||
assert.throws(() => validate({ eventId: 'test', status: 'PRINTED' }, schema.$defs.JobEvent, 'invalidStatus'));
|
||||
assert.throws(() => validate({ name: 'x'.repeat(513) }, schema.$defs.DeviceProfile, 'oversizedName'));
|
||||
assert.throws(() => validate({ description: 'x'.repeat(4097) }, schema.$defs.DeviceProfile, 'oversizedDescription'));
|
||||
assert.throws(() => validate({ location: 'x'.repeat(513) }, schema.$defs.DeviceProfile, 'oversizedLocation'));
|
||||
validate({ name: '', description: null }, schema.$defs.DeviceProfile, 'clearAndPreserve');
|
||||
const brandedTest = fixtures['device-test-profile.request.json'];
|
||||
assert.throws(() => validate({ ...brandedTest, testProfile: 'LETTER' }, schema.$defs.DeviceTestPrintRequest, 'incompatibleLabelMedia'));
|
||||
assert.throws(() => validate({ ...brandedTest, testProfile: 'UNKNOWN' }, schema.$defs.DeviceTestPrintRequest, 'unknownTestProfile'));
|
||||
assert.throws(() => validate({ ...brandedTest, testProfile: null }, schema.$defs.DeviceTestPrintRequest, 'nullTestProfile'));
|
||||
assert.throws(() => validate({ format: 'PDF' }, schema.$defs.DeviceTestPrintRequest, 'missingTestIdentity'));
|
||||
validate({ ...brandedTest, format: 'PDF', testProfile: 'A4' }, schema.$defs.DeviceTestPrintRequest, 'a4TestPage');
|
||||
const clear = fixtures['action-clear-queue.request.json'];
|
||||
assert.throws(() => validate({ ...clear, issuedAt: undefined }, schema.$defs.AdminDeviceActionRequest, 'missingIssuedAt'));
|
||||
assert.throws(() => validate({ ...clear, jobId: 'job-example-1' }, schema.$defs.AdminDeviceActionRequest, 'clearCannotTargetSpool'));
|
||||
assert.throws(() => validate({ ...clear, action: 'RESTART_SERVICE' }, schema.$defs.AdminDeviceActionRequest, 'arbitraryServiceControl'));
|
||||
assert.throws(() => validate({ ...clear, command: 'shell' }, schema.$defs.AdminDeviceActionRequest, 'unknownCommandProperty'));
|
||||
assert.throws(() => validate({ ...clear, action: 'CANCEL_SPOOL' }, schema.$defs.AdminDeviceActionRequest, 'cancelRequiresJobIdentity'));
|
||||
assert.throws(() => validate({ ...fixtures['control-poll.request.json'], capabilities: ['CANCEL_SPOOL', 'CANCEL_SPOOL'] }, schema.$defs.DeviceControlPollRequest, 'duplicateCapability'));
|
||||
assert.throws(() => validate({ ...fixtures['control-cancel-requested.receipt.json'], outcome: 'PRINT_RECALLED' }, schema.$defs.DeviceCommandReceipt, 'unsupportedPaperClaim'));
|
||||
assert.equal(createHash("sha256").update(content).digest("hex"), job.contentSha256);
|
||||
assert.deepEqual(Buffer.from((/** @type {typeof import("../fixtures/submit.request.json")} */ (fixtures["submit.request.json"])).contentBase64, "base64"), content);
|
||||
assert.deepEqual(fixtures["job.response.json"], job);
|
||||
assert.throws(() => validate({ copies: 0 }, /** @type {Rule} */ (schema.$defs["PrintOptions"]), "invalidCopies"));
|
||||
assert.throws(() => validate({ paperWidthMm: 100 }, /** @type {Rule} */ (schema.$defs["PrintOptions"]), "missingHeight"));
|
||||
assert.throws(() => validate({ eventId: "test", status: "PRINTED" }, /** @type {Rule} */ (schema.$defs["JobEvent"]), "invalidStatus"));
|
||||
assert.throws(() => validate({ name: "x".repeat(513) }, /** @type {Rule} */ (schema.$defs["DeviceProfile"]), "oversizedName"));
|
||||
assert.throws(() => validate({ description: "x".repeat(4097) }, /** @type {Rule} */ (schema.$defs["DeviceProfile"]), "oversizedDescription"));
|
||||
assert.throws(() => validate({ location: "x".repeat(513) }, /** @type {Rule} */ (schema.$defs["DeviceProfile"]), "oversizedLocation"));
|
||||
validate({ name: "", description: null }, /** @type {Rule} */ (schema.$defs["DeviceProfile"]), "clearAndPreserve");
|
||||
const brandedTest = fixtures["device-test-profile.request.json"];
|
||||
assert.throws(() => validate({ ...brandedTest, testProfile: "LETTER" }, /** @type {Rule} */ (schema.$defs["DeviceTestPrintRequest"]), "incompatibleLabelMedia"));
|
||||
assert.throws(() => validate({ ...brandedTest, testProfile: "UNKNOWN" }, /** @type {Rule} */ (schema.$defs["DeviceTestPrintRequest"]), "unknownTestProfile"));
|
||||
assert.throws(() => validate({ ...brandedTest, testProfile: null }, /** @type {Rule} */ (schema.$defs["DeviceTestPrintRequest"]), "nullTestProfile"));
|
||||
assert.throws(() => validate({ format: "PDF" }, /** @type {Rule} */ (schema.$defs["DeviceTestPrintRequest"]), "missingTestIdentity"));
|
||||
validate({ ...brandedTest, format: "PDF", testProfile: "A4" }, /** @type {Rule} */ (schema.$defs["DeviceTestPrintRequest"]), "a4TestPage");
|
||||
for(const testProfile of ["LABEL_4X2_203", "LABEL_4X2_300", "LABEL_4X2_600", "LABEL_4X1_203", "LABEL_4X1_300", "LABEL_4X1_600"])
|
||||
{
|
||||
validate({ ...brandedTest, testProfile }, /** @type {Rule} */ (schema.$defs["DeviceTestPrintRequest"]), `zplShortLabel${testProfile}`);
|
||||
validate({ ...brandedTest, format: "PDF", testProfile }, /** @type {Rule} */ (schema.$defs["DeviceTestPrintRequest"]), `pdfShortLabel${testProfile}`);
|
||||
}
|
||||
assert.throws(() => validate({ ...brandedTest, testProfile: "LABEL_4X3_300" }, /** @type {Rule} */ (schema.$defs["DeviceTestPrintRequest"]), "unsupportedLabelHeight"));
|
||||
// Short PDF labels use explicit actual-size job paper, never a conflicting media selection.
|
||||
validate({ copies: 1, scale: "ACTUAL_SIZE", paperWidthMm: 101.6, paperHeightMm: 25.4 }, /** @type {Rule} */ (schema.$defs["PrintOptions"]), "fourByOneJobPaper");
|
||||
validate({ copies: 1, scale: "ACTUAL_SIZE", paperWidthMm: 101.6, paperHeightMm: 50.8 }, /** @type {Rule} */ (schema.$defs["PrintOptions"]), "fourByTwoJobPaper");
|
||||
const clear = fixtures["action-clear-queue.request.json"];
|
||||
assert.throws(() => validate({ ...clear, issuedAt: undefined }, /** @type {Rule} */ (schema.$defs["AdminDeviceActionRequest"]), "missingIssuedAt"));
|
||||
assert.throws(() => validate({ ...clear, jobId: "job-example-1" }, /** @type {Rule} */ (schema.$defs["AdminDeviceActionRequest"]), "clearCannotTargetSpool"));
|
||||
assert.throws(() => validate({ ...clear, action: "RESTART_SERVICE" }, /** @type {Rule} */ (schema.$defs["AdminDeviceActionRequest"]), "arbitraryServiceControl"));
|
||||
assert.throws(() => validate({ ...clear, command: "shell" }, /** @type {Rule} */ (schema.$defs["AdminDeviceActionRequest"]), "unknownCommandProperty"));
|
||||
assert.throws(() => validate({ ...clear, action: "CANCEL_SPOOL" }, /** @type {Rule} */ (schema.$defs["AdminDeviceActionRequest"]), "cancelRequiresJobIdentity"));
|
||||
assert.throws(() => validate({ ...fixtures["control-poll.request.json"], capabilities: ["CANCEL_SPOOL", "CANCEL_SPOOL"] }, /** @type {Rule} */ (schema.$defs["DeviceControlPollRequest"]), "duplicateCapability"));
|
||||
assert.throws(() => validate({ ...fixtures["control-cancel-requested.receipt.json"], outcome: "PRINT_RECALLED" }, /** @type {Rule} */ (schema.$defs["DeviceCommandReceipt"]), "unsupportedPaperClaim"));
|
||||
console.log(`Validated ${Object.keys(fixtures).length} protocol fixtures, content integrity, and invalid-input checks.`);
|
||||
|
||||
const media = fixtures["printer-media-profile.response.json"];
|
||||
for(const invalid of [{ dpiX: 0 }, { widthMicrons: 0 }, { revision: "not-a-revision" }, { applyMode: "CHANGE_STOCK" }])
|
||||
{
|
||||
assert.throws(() => validate({ ...media, ...invalid }, /** @type {Rule} */ (schema.$defs["PrinterMediaProfile"]), "invalidMedia"));
|
||||
}
|
||||
const selection = fixtures["media-profile-options.request.json"];
|
||||
assert.throws(() => validate({ ...selection, paperWidthMm: 100, paperHeightMm: 150 }, /** @type {Rule} */ (schema.$defs["PrintOptions"]), "ambiguousMedia"));
|
||||
|
||||
const operatorRequest = fixtures["operator-submit.request.json"];
|
||||
for(const invalid of [{ requestId: "x".repeat(129) }, { requestId: "echo/scan" }, { actorId: "forged" }, { tenantId: "forged" }])
|
||||
{
|
||||
assert.throws(() => validate({ ...operatorRequest, ...invalid }, /** @type {Rule} */ (schema.$defs["OperatorSubmitJobRequest"]), "invalidOperatorRequest"));
|
||||
}
|
||||
validate({ ...operatorRequest, requestId: "x".repeat(128) }, /** @type {Rule} */ (schema.$defs["OperatorSubmitJobRequest"]), "maximumRequestKey");
|
||||
+104
-55
@@ -1,62 +1,76 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import Ajv2020 from 'ajv/dist/2020.js';
|
||||
import addFormats from 'ajv-formats';
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import addFormats from "ajv-formats";
|
||||
|
||||
const schema = JSON.parse(readFileSync(new URL('../schemas/protocol.schema.json', import.meta.url), 'utf8'));
|
||||
const schema = JSON.parse(readFileSync(new URL("../schemas/protocol.schema.json", import.meta.url), "utf8"));
|
||||
const ajv = new Ajv2020({ strict: true, allErrors: true });
|
||||
addFormats(ajv);
|
||||
// ajv-formats is callable CommonJS; its NodeNext declaration exposes a namespace.
|
||||
/** @type {import("ajv-formats").FormatsPlugin} */ (/** @type {unknown} */ (addFormats))(ajv);
|
||||
ajv.addSchema(schema);
|
||||
const fixture = name => JSON.parse(readFileSync(new URL(`../fixtures/${name}`, import.meta.url), 'utf8'));
|
||||
/** @param {string} name @returns {Record<string,unknown>} */
|
||||
const fixture = name => JSON.parse(readFileSync(new URL(`../fixtures/${name}`, import.meta.url), "utf8"));
|
||||
/** @param {string} type @param {unknown} value @returns {boolean|Promise<unknown>} */
|
||||
const valid = (type, value) => ajv.validate(`${schema.$id}#/$defs/${type}`, value);
|
||||
const settings = fixture('realtime-settings.payload.json');
|
||||
const hello = fixture('realtime-hello.message.json');
|
||||
const extension = fixture('printing-realtime-extension.discovery.json');
|
||||
assert(valid('PrintingRealtimeExtensionDiscovery', extension), ajv.errorsText());
|
||||
assert.equal(extension.version, '2.0.0');
|
||||
assert(valid('PrintingRealtimeExtensionDiscovery', { ...extension, version: '2.7.3' }));
|
||||
for(const version of ['1.0.0', '1.1.0', '3.0.0', '2.0', '2.01.0', '2.0.0-rc.1', null])
|
||||
const settings = /** @type {typeof import("../fixtures/realtime-settings.payload.json")} */ (/** @type {unknown} */ (fixture("realtime-settings.payload.json")));
|
||||
const hello = /** @type {typeof import("../fixtures/realtime-hello.message.json")} */ (/** @type {unknown} */ (fixture("realtime-hello.message.json")));
|
||||
const extension = fixture("printing-realtime-extension.discovery.json");
|
||||
assert(valid("PrintingRealtimeExtensionDiscovery", extension), ajv.errorsText());
|
||||
assert.equal(extension["version"], "2.0.0");
|
||||
assert(valid("PrintingRealtimeExtensionDiscovery", { ...extension, version: "2.7.3" }));
|
||||
for(const version of ["1.0.0", "1.1.0", "3.0.0", "2.0", "2.01.0", "2.0.0-rc.1", null])
|
||||
{
|
||||
assert(!valid('PrintingRealtimeExtensionDiscovery', { ...extension, version }), 'Only the compatible stable native major is accepted.');
|
||||
assert(!valid("PrintingRealtimeExtensionDiscovery", { ...extension, version }), "Only the compatible stable native major is accepted.");
|
||||
}
|
||||
assert(!valid('PrintingRealtimeExtensionDiscovery', { ...extension, name: 'another-extension' }));
|
||||
assert(!valid('PrintingRealtimeExtensionDiscovery', { ...extension, pathPrefix: '/printing/v1' }));
|
||||
assert(!valid("PrintingRealtimeExtensionDiscovery", { ...extension, name: "another-extension" }));
|
||||
assert(!valid("PrintingRealtimeExtensionDiscovery", { ...extension, pathPrefix: "/printing/v1" }));
|
||||
const approvalExtension = fixture("printing-realtime-extension-240.discovery.json");
|
||||
assert(valid("PrintingRealtimeExtensionDiscovery", approvalExtension), ajv.errorsText());
|
||||
assert.equal(approvalExtension["version"], "2.4.0", "printer-approval-v1 has feature minimum 2.4.0.");
|
||||
assert.deepEqual({ ...approvalExtension, version: extension["version"] }, extension, "2.4.0 keeps the 2.x descriptor name and path prefix.");
|
||||
|
||||
/*******************************************************************************
|
||||
** Cross-field policy relationships require semantic checks beyond JSON Schema.
|
||||
*******************************************************************************/
|
||||
/** @param {typeof settings} policy @param {typeof hello.payload.limits} limits @returns {void} */
|
||||
function assertPolicy(policy, limits)
|
||||
{
|
||||
assert(valid('RealtimeSettings', policy), ajv.errorsText());
|
||||
assert(valid('RealtimeClientLimits', limits), ajv.errorsText());
|
||||
assert(policy.maxQueuedFrameBytes >= policy.maxFrameBytes, 'Frame queue must hold one permitted message.');
|
||||
assert(policy.maxStagedBytes >= Math.max(policy.maxPdfBytes, policy.maxZplBytes), 'Staging must admit one permitted document.');
|
||||
assert(valid("RealtimeSettings", policy), ajv.errorsText());
|
||||
assert(valid("RealtimeClientLimits", limits), ajv.errorsText());
|
||||
assert(policy.maxQueuedFrameBytes >= policy.maxFrameBytes, "Frame queue must hold one permitted message.");
|
||||
assert(policy.maxStagedBytes >= Math.max(policy.maxPdfBytes, policy.maxZplBytes), "Staging must admit one permitted document.");
|
||||
assert(policy.downloadTimeoutMillis >= policy.downloadConnectTimeoutMillis);
|
||||
assert(policy.downloadTimeoutMillis >= policy.downloadIdleTimeoutMillis);
|
||||
assert(policy.heartbeatTimeoutSeconds >= 2 * policy.heartbeatIntervalSeconds);
|
||||
assert(policy.reconnectMaxDelayMillis >= policy.reconnectMinDelayMillis);
|
||||
for(const name of schema.$defs.RealtimeClientLimits.required)
|
||||
for(const name of /** @type {(keyof typeof hello.payload.limits)[]} */ (schema.$defs.RealtimeClientLimits.required))
|
||||
{
|
||||
assert(policy[name] <= limits[name], `${name}: policy exceeds declared client capability`);
|
||||
}
|
||||
}
|
||||
|
||||
assertPolicy(settings, hello.payload.limits);
|
||||
assert.equal(settings.maxConcurrentDownloads, 1, 'Initial deployment defaults to one configurable download lane.');
|
||||
assert.equal(settings.maxConcurrentDownloads, 1, "Initial deployment defaults to one configurable download lane.");
|
||||
assert.equal(settings.maxCopies, 100);
|
||||
assert.equal(settings.nativeSubmissionTimeoutMillis, 120000);
|
||||
for(const [name, rule] of Object.entries(schema.$defs.RealtimeSettings.properties))
|
||||
for(const [name, rule] of Object.entries(/** @type {Record<keyof typeof settings,{default?:number,minimum:number,maximum:number}>} */ (schema.$defs.RealtimeSettings.properties)))
|
||||
{
|
||||
if(rule.default !== undefined) assert.equal(settings[name], rule.default, `${name}: fixture/schema default mismatch`);
|
||||
if(rule.default !== undefined)
|
||||
{
|
||||
assert.equal(settings[/** @type {keyof typeof settings} */ (name)], rule.default, `${name}: fixture/schema default mismatch`);
|
||||
}
|
||||
/** @type {Partial<typeof settings>} */
|
||||
const missing = { ...settings };
|
||||
delete missing[name];
|
||||
assert(!valid('RealtimeSettings', missing), `${name}: missing value cannot inherit a default`);
|
||||
// Negative evidence deliberately removes each required schema field.
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete missing[/** @type {keyof typeof settings} */ (name)];
|
||||
assert(!valid("RealtimeSettings", missing), `${name}: missing value cannot inherit a default`);
|
||||
for(const value of [rule.minimum - 1, rule.maximum + 1, 1.5, null])
|
||||
{
|
||||
assert(!valid('RealtimeSettings', { ...settings, [name]: value }), `${name}: invalid bound/type accepted`);
|
||||
assert(!valid("RealtimeSettings", { ...settings, [name]: value }), `${name}: invalid bound/type accepted`);
|
||||
}
|
||||
}
|
||||
for(const change of [
|
||||
@@ -64,50 +78,85 @@ for(const change of [
|
||||
{ maxStagedBytes: 1 },
|
||||
{ downloadTimeoutMillis: 1000 },
|
||||
{ heartbeatTimeoutSeconds: 10 },
|
||||
{ reconnectMinDelayMillis: 60000, reconnectMaxDelayMillis: 1000 }
|
||||
{ reconnectMinDelayMillis: 60000, reconnectMaxDelayMillis: 1000 },
|
||||
])
|
||||
{
|
||||
assert.throws(() => assertPolicy({ ...settings, ...change }, hello.payload.limits));
|
||||
}
|
||||
for(const name of schema.$defs.RealtimeClientLimits.required)
|
||||
for(const name of /** @type {(keyof typeof hello.payload.limits)[]} */ (schema.$defs.RealtimeClientLimits.required))
|
||||
{
|
||||
assert.throws(() => assertPolicy(settings, { ...hello.payload.limits, [name]: settings[name] - 1 }), name);
|
||||
assert.throws(() => assertPolicy(settings, { ...hello.payload.limits, [name]: settings[/** @type {keyof typeof settings} */ (name)] - 1 }), name);
|
||||
}
|
||||
assertPolicy({ ...settings, maxConcurrentDownloads: 16, maxConcurrentPrints: 16, maxCopies: 32767 }, hello.payload.limits);
|
||||
assert(!valid('RealtimeHello', { ...hello, payload: { ...hello.payload, capabilities: [] } }));
|
||||
assert(!valid('RealtimeHello', { ...hello, payload: { ...hello.payload, capabilities: ['CANCEL_SPOOL'] } }));
|
||||
assert(!valid('RealtimeHello', { ...hello, payload: { ...hello.payload, limits: {} } }));
|
||||
assert(!valid('RealtimeHello', { ...hello, messageId: 'hello:unsafe-prefix' }));
|
||||
assert(!valid('RealtimeHello', { ...hello, token: 'not-a-real-token' }));
|
||||
assert(!valid('RealtimeClientMessage', { type: 'RUN_SHELL', messageId: 'unsupported-1', payload: {} }));
|
||||
assert(!valid("RealtimeHello", { ...hello, payload: { ...hello.payload, capabilities: [] } }));
|
||||
assert(!valid("RealtimeHello", { ...hello, payload: { ...hello.payload, capabilities: ["CANCEL_SPOOL"] } }));
|
||||
assert(!valid("RealtimeHello", { ...hello, payload: { ...hello.payload, limits: {} } }));
|
||||
assert(!valid("RealtimeHello", { ...hello, messageId: "hello:unsafe-prefix" }));
|
||||
assert(!valid("RealtimeHello", { ...hello, token: "not-a-real-token" }));
|
||||
assert(!valid("RealtimeClientMessage", { type: "RUN_SHELL", messageId: "unsupported-1", payload: {} }));
|
||||
|
||||
const clientFixtures = ['hello', 'state-applied', 'job-event', 'command-receipt', 'heartbeat', 'inventory', 'ping'];
|
||||
const serverFixtures = ['welcome', 'desired-state', 'job-available', 'command', 'settings', 'ack', 'error', 'pong'];
|
||||
for(const [files, accepted, rejected] of [
|
||||
[clientFixtures, 'RealtimeClientMessage', 'RealtimeServerMessage'],
|
||||
[serverFixtures, 'RealtimeServerMessage', 'RealtimeClientMessage']
|
||||
])
|
||||
/*******************************************************************************
|
||||
** Capability hellos: the 2.3.0 four-capability and 2.4.0 five-capability peers
|
||||
** both remain valid; the list is bounded, unique and closed.
|
||||
*******************************************************************************/
|
||||
const mediaHello = /** @type {typeof hello} */ (/** @type {unknown} */ (fixture("realtime-media-hello.message.json")));
|
||||
const approvalHello = /** @type {typeof import("../fixtures/realtime-approval-hello.message.json")} */ (/** @type {unknown} */ (fixture("realtime-approval-hello.message.json")));
|
||||
assert(valid("RealtimeHello", mediaHello), ajv.errorsText());
|
||||
assert(valid("RealtimeHello", approvalHello), ajv.errorsText());
|
||||
assert.equal(approvalHello.payload.capabilities.length, 5);
|
||||
assert(approvalHello.payload.capabilities.includes("printer-approval-v1"));
|
||||
assert.deepEqual(approvalHello.payload.limits, hello.payload.limits, "Approval negotiation does not change declared ceilings.");
|
||||
assert(!valid("RealtimeHello", { ...approvalHello, payload: { ...approvalHello.payload, capabilities: [...approvalHello.payload.capabilities, "printer-approval-v1"] } }), "Duplicate capability accepted");
|
||||
assert(!valid("RealtimeHello", { ...approvalHello, payload: { ...approvalHello.payload, capabilities: [...approvalHello.payload.capabilities, "printer-approval-v2"] } }), "Unknown sixth capability accepted");
|
||||
assert(!valid("RealtimeHello", { ...hello, payload: { ...hello.payload, capabilities: ["printer-approval-v1"] } }), "realtime-v1 remains mandatory");
|
||||
|
||||
const clientFixtures = ["hello", "state-applied", "job-event", "command-receipt", "heartbeat", "inventory", "ping"];
|
||||
const serverFixtures = ["welcome", "desired-state", "job-available", "printers", "command", "settings", "ack", "error", "pong"];
|
||||
for(const [files, accepted, rejected] of /** @type {[string[],string,string][]} */ ([
|
||||
[clientFixtures, "RealtimeClientMessage", "RealtimeServerMessage"],
|
||||
[serverFixtures, "RealtimeServerMessage", "RealtimeClientMessage"],
|
||||
]))
|
||||
{
|
||||
for(const name of files)
|
||||
{
|
||||
const message = fixture(`realtime-${name}.message.json`);
|
||||
assert(valid(accepted, message), `${name}: ${ajv.errorsText()}`);
|
||||
assert(!valid(rejected, message), `${name}: wrong-direction frame accepted`);
|
||||
assert(Buffer.byteLength(JSON.stringify(message), 'utf8') <= settings.maxFrameBytes);
|
||||
assert(Buffer.byteLength(JSON.stringify(message), "utf8") <= settings.maxFrameBytes);
|
||||
const missing = { ...message };
|
||||
delete missing.payload;
|
||||
delete missing["payload"];
|
||||
assert(!valid(accepted, missing), `${name}: missing payload accepted`);
|
||||
assert(!valid(accepted, { ...message, contentUrl: 'https://unrelated.invalid/document' }));
|
||||
assert(!valid(accepted, { ...message, contentUrl: "https://unrelated.invalid/document" }));
|
||||
}
|
||||
}
|
||||
const event = fixture('realtime-job-event.message.json');
|
||||
assert.deepEqual(fixture('realtime-job-event-replay.message.json'), event, 'Lost ACK replay preserves the exact envelope.');
|
||||
assert.equal(fixture('realtime-ack.message.json').payload.acknowledgedMessageId, event.messageId);
|
||||
assert.equal(fixture('realtime-error.message.json').messageId, event.messageId, 'Frame errors retain request correlation.');
|
||||
const largeJob = fixture('realtime-large-pdf.response.json');
|
||||
assert(valid('PrintJob', largeJob), ajv.errorsText());
|
||||
/*******************************************************************************
|
||||
** PRINTERS carries the complete approval state of the latest accepted inventory;
|
||||
** entries are bounded, both fields are required and the envelope stays closed.
|
||||
*******************************************************************************/
|
||||
const printers = /** @type {typeof import("../fixtures/realtime-printers.message.json")} */ (/** @type {unknown} */ (fixture("realtime-printers.message.json")));
|
||||
assert.equal(printers.payload.printers.length, 2);
|
||||
assert.deepEqual(printers.payload.printers.map(printer => printer.enabled), [true, false], "Fixture shows one approved and one awaiting printer.");
|
||||
assert(valid("RealtimePrinters", { ...printers, payload: { printers: [] } }), "An empty inventory yields an empty approval list.");
|
||||
assert(valid("RealtimePrinters", { ...printers, payload: { printers: Array(200).fill(printers.payload.printers[0]) } }));
|
||||
assert(!valid("RealtimePrinters", { ...printers, payload: { printers: Array(201).fill(printers.payload.printers[0]) } }));
|
||||
assert(valid("RealtimePrinters", { ...printers, payload: { ...printers.payload, futureField: true } }), "Additive payload fields remain compatible.");
|
||||
assert(valid("RealtimePrinters", { ...printers, payload: { printers: [{ ...printers.payload.printers[0], futureField: true }] } }));
|
||||
assert(!valid("RealtimePrinters", { ...printers, payload: { printers: [{ printerId: "laser-example" }] } }), "enabled is required");
|
||||
assert(!valid("RealtimePrinters", { ...printers, payload: { printers: [{ enabled: true }] } }), "printerId is required");
|
||||
assert(!valid("RealtimePrinters", { ...printers, payload: { printers: [{ printerId: "laser-example", enabled: "true" }] } }), "enabled must be boolean");
|
||||
assert(!valid("RealtimePrinters", { ...printers, payload: { printers: [{ printerId: "", enabled: true }] } }));
|
||||
assert(!valid("RealtimePrinters", { ...printers, payload: {} }), "printers list is required");
|
||||
assert(!valid("RealtimePrinters", { ...printers, type: "JOB_AVAILABLE" }));
|
||||
assert(!valid("RealtimeJobAvailable", { ...printers, type: "JOB_AVAILABLE" }), "PRINTERS payload is not the empty wake-up");
|
||||
|
||||
const event = fixture("realtime-job-event.message.json");
|
||||
assert.deepEqual(fixture("realtime-job-event-replay.message.json"), event, "Lost ACK replay preserves the exact envelope.");
|
||||
assert.equal((/** @type {typeof import("../fixtures/realtime-ack.message.json")} */ (/** @type {unknown} */ (fixture("realtime-ack.message.json")))).payload.acknowledgedMessageId, event["messageId"]);
|
||||
assert.equal(fixture("realtime-error.message.json")["messageId"], event["messageId"], "Frame errors retain request correlation.");
|
||||
const largeJob = /** @type {typeof import("../fixtures/realtime-large-pdf.response.json")} */ (/** @type {unknown} */ (fixture("realtime-large-pdf.response.json")));
|
||||
assert(valid("PrintJob", largeJob), ajv.errorsText());
|
||||
assert.equal(largeJob.contentLength, settings.maxPdfBytes);
|
||||
assert(largeJob.contentLength > 20 * 1024 * 1024);
|
||||
assert(valid('PrintOptions', { copies: 32767 }));
|
||||
assert(!valid('PrintOptions', { copies: 32768 }));
|
||||
console.log('Realtime directional frames, defaults/ranges, policy/ceiling refusal and exact replay fixtures passed; runtime concurrency acceptance remains consumer-owned.');
|
||||
assert(valid("PrintOptions", { copies: 32767 }));
|
||||
assert(!valid("PrintOptions", { copies: 32768 }));
|
||||
console.log("Realtime directional frames, defaults/ranges, policy/ceiling refusal and exact replay fixtures passed; runtime concurrency acceptance remains consumer-owned.");
|
||||
+89
-57
@@ -1,16 +1,17 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import Ajv2020 from 'ajv/dist/2020.js';
|
||||
import addFormats from 'ajv-formats';
|
||||
import { fixtureTypes } from './fixture-types.mjs';
|
||||
import './verify-realtime.mjs';
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import addFormats from "ajv-formats";
|
||||
import { fixtureTypes } from "./fixture-types.mjs";
|
||||
import "./verify-realtime.mjs";
|
||||
|
||||
const schema = JSON.parse(readFileSync(new URL('../schemas/protocol.schema.json', import.meta.url), 'utf8'));
|
||||
const schema = JSON.parse(readFileSync(new URL("../schemas/protocol.schema.json", import.meta.url), "utf8"));
|
||||
const ajv = new Ajv2020({ strict: true, allErrors: true });
|
||||
addFormats(ajv);
|
||||
// ajv-formats is callable CommonJS; its NodeNext declaration exposes a namespace.
|
||||
/** @type {import("ajv-formats").FormatsPlugin} */ (/** @type {unknown} */ (addFormats))(ajv);
|
||||
assert(ajv.validateSchema(schema), ajv.errorsText());
|
||||
ajv.addSchema(schema);
|
||||
|
||||
@@ -21,69 +22,100 @@ for(const name of Object.keys(schema.$defs))
|
||||
}
|
||||
for(const [file, type] of Object.entries(fixtureTypes))
|
||||
{
|
||||
const value = JSON.parse(readFileSync(new URL(`../fixtures/${file}`, import.meta.url), 'utf8'));
|
||||
const value = JSON.parse(readFileSync(new URL(`../fixtures/${file}`, import.meta.url), "utf8"));
|
||||
const validate = ajv.compile({ $ref: `${schema.$id}#/$defs/${type}` });
|
||||
assert(validate(value), `${file}: ${ajv.errorsText(validate.errors)}`);
|
||||
}
|
||||
const validateTest = ajv.compile({ $ref: `${schema.$id}#/$defs/DeviceTestPrintRequest` });
|
||||
const testRequest = { printerId: 'printer-zebra', format: 'ZPL', requestId: 'test-1' };
|
||||
assert(validateTest(testRequest), 'Existing requests without a profile remain valid.');
|
||||
assert(validateTest({ ...testRequest, testProfile: 'LABEL_4X6_300' }));
|
||||
assert(!validateTest({ ...testRequest, testProfile: 'LETTER' }));
|
||||
const testRequest = { printerId: "printer-zebra", format: "ZPL", requestId: "test-1" };
|
||||
assert(validateTest(testRequest), "Existing requests without a profile remain valid.");
|
||||
assert(validateTest({ ...testRequest, testProfile: "LABEL_4X6_300" }));
|
||||
assert(validateTest({ ...testRequest, testProfile: "LABEL_4X1_203" }));
|
||||
assert(validateTest({ ...testRequest, testProfile: "LABEL_4X2_600" }));
|
||||
assert(validateTest({ ...testRequest, format: "PDF", testProfile: "LABEL_4X2_300" }));
|
||||
assert(!validateTest({ ...testRequest, testProfile: "LABEL_4X3_300" }));
|
||||
assert(!validateTest({ ...testRequest, testProfile: "LETTER" }));
|
||||
assert(!validateTest({ ...testRequest, testProfile: null }));
|
||||
assert(!validateTest({ format: 'PDF' }));
|
||||
assert(!validateTest({ format: "PDF" }));
|
||||
const validatePrinters = ajv.compile({ $ref: `${schema.$id}#/$defs/PrinterList` });
|
||||
const printerPage = JSON.parse(readFileSync(new URL('../fixtures/printers-page.response.json', import.meta.url), 'utf8'));
|
||||
/** @type {typeof import("../fixtures/printers-page.response.json")} */
|
||||
const printerPage = JSON.parse(readFileSync(new URL("../fixtures/printers-page.response.json", import.meta.url), "utf8"));
|
||||
assert(validatePrinters(printerPage));
|
||||
assert(validatePrinters({ printers: printerPage.printers }), 'Older responses without a cursor remain valid.');
|
||||
assert(validatePrinters({ ...printerPage, nextCursor: 'opaque-server-cursor' }));
|
||||
assert(validatePrinters({ printers: printerPage.printers }), "Older responses without a cursor remain valid.");
|
||||
assert(validatePrinters({ ...printerPage, nextCursor: "opaque-server-cursor" }));
|
||||
assert(!validatePrinters({ ...printerPage, nextCursor: 17 }));
|
||||
assert(!validatePrinters({ ...printerPage, nextCursor: 'x'.repeat(12001) }));
|
||||
assert(!validatePrinters({ ...printerPage, nextCursor: "x".repeat(12001) }));
|
||||
assert(!validatePrinters({ printers: Array(201).fill(printerPage.printers[0]) }));
|
||||
const control = name => JSON.parse(readFileSync(new URL(`../fixtures/${name}`, import.meta.url), 'utf8'));
|
||||
/** @param {string} name @returns {Record<string,unknown>} */
|
||||
const control = name => JSON.parse(readFileSync(new URL(`../fixtures/${name}`, import.meta.url), "utf8"));
|
||||
/** @param {string} type @param {unknown} value @returns {boolean|Promise<unknown>} */
|
||||
const validControl = (type, value) => ajv.validate(`${schema.$id}#/$defs/${type}`, value);
|
||||
assert(!validControl('PrintingSessionIdentity', { tenantId: 'test-tenant' }), 'Display or device identity cannot replace the authoritative principal.');
|
||||
assert(!validControl('PrintingSessionIdentity', { principalId: 'test-operator', tenantId: '' }));
|
||||
assert(!validControl('PrintingSessionIdentity', { principalId: 'test-operator', tenantId: 'test-tenant', deviceId: null }));
|
||||
assert(validControl('PrintingSessionIdentity', { principalId: 'test-operator', tenantId: 'test-tenant', futureResponseField: true }));
|
||||
const action = control('action-clear-queue.request.json');
|
||||
assert(validControl('AdminDeviceActionRequest', action));
|
||||
assert(!validControl("PrintingSessionIdentity", { tenantId: "test-tenant" }), "Display or device identity cannot replace the authoritative principal.");
|
||||
assert(!validControl("PrintingSessionIdentity", { principalId: "test-operator", tenantId: "" }));
|
||||
assert(!validControl("PrintingSessionIdentity", { principalId: "test-operator", tenantId: "test-tenant", deviceId: null }));
|
||||
assert(validControl("PrintingSessionIdentity", { principalId: "test-operator", tenantId: "test-tenant", futureResponseField: true }));
|
||||
const action = control("action-clear-queue.request.json");
|
||||
assert(validControl("AdminDeviceActionRequest", action));
|
||||
const withoutIssuedAt = { ...action };
|
||||
delete withoutIssuedAt.issuedAt;
|
||||
assert(!validControl('AdminDeviceActionRequest', withoutIssuedAt), 'New controls require their durable freshness timestamp.');
|
||||
delete withoutIssuedAt["issuedAt"];
|
||||
assert(!validControl("AdminDeviceActionRequest", withoutIssuedAt), "New controls require their durable freshness timestamp.");
|
||||
for(const invalid of [
|
||||
{ issuedAt: '2026-10-31T16:00:00-05:00' },
|
||||
{ issuedAt: "2026-10-31T16:00:00-05:00" },
|
||||
{ issuedAt: null },
|
||||
{ action: 'RESTART_SERVICE' },
|
||||
{ action: 'CANCEL_SPOOL' },
|
||||
{ jobId: 'job-example-1' },
|
||||
{ spoolerJobId: '17' },
|
||||
{ queueName: 'Some other queue' },
|
||||
{ command: 'shell' },
|
||||
{ requestId: '../device' }
|
||||
{ action: "RESTART_SERVICE" },
|
||||
{ action: "CANCEL_SPOOL" },
|
||||
{ jobId: "job-example-1" },
|
||||
{ spoolerJobId: "17" },
|
||||
{ queueName: "Some other queue" },
|
||||
{ command: "shell" },
|
||||
{ requestId: "../device" },
|
||||
])
|
||||
{
|
||||
assert(!validControl('AdminDeviceActionRequest', { ...action, ...invalid }), JSON.stringify(invalid));
|
||||
assert(!validControl("AdminDeviceActionRequest", { ...action, ...invalid }), JSON.stringify(invalid));
|
||||
}
|
||||
const poll = control('control-poll.request.json');
|
||||
assert(!validControl('DeviceControlPollRequest', { ...poll, controlVersion: 2 }));
|
||||
assert(!validControl('DeviceControlPollRequest', { ...poll, capabilities: ['CANCEL_SPOOL', 'CANCEL_SPOOL'] }));
|
||||
assert(!validControl('DeviceControlPollRequest', { ...poll, capabilities: ['RESTART_SERVICE'] }));
|
||||
assert(!validControl('DeviceControlPollRequest', { ...poll, lastAppliedRevision: -1 }));
|
||||
assert(!validControl('DeviceControlPollRequest', { ...poll, lastAppliedRevision: 9007199254740992 }));
|
||||
const pollResult = control('control-poll.response.json');
|
||||
assert(validControl('DeviceControlPollResponse', { ...pollResult, futureResponseField: true }), 'Additive response fields remain compatible.');
|
||||
assert(!validControl('DeviceControlPollResponse', { ...pollResult, commands: Array(21).fill(pollResult.commands[0]) }));
|
||||
assert(!validControl('DeviceControlPollResponse', { ...pollResult, desiredState: { ...pollResult.desiredState, connectionState: 'REVOKED' } }), 'Revocation is credential invalidation, not reversible desired state.');
|
||||
assert(!validControl('DeviceCommand', { ...pollResult.commands[1], jobId: null }));
|
||||
assert(!validControl('DeviceCommand', { ...pollResult.commands[0], jobId: 'job-example-1' }));
|
||||
assert(!validControl('DeviceCommand', { ...pollResult.commands[0], action: 'CLEAR_QUEUE' }), 'Server queue clear is never an arbitrary client command.');
|
||||
const receipt = control('control-cancel-requested.receipt.json');
|
||||
assert(!validControl('DeviceCommandReceipt', { ...receipt, outcome: 'PRINT_RECALLED' }));
|
||||
assert(!validControl('DeviceCommandReceipt', { ...receipt, message: 'x'.repeat(1025) }));
|
||||
assert(!validControl('DeviceCommandReceipt', { ...receipt, completedAt: 'not-a-date' }));
|
||||
assert(!validControl('DeviceCommandReceipt', { ...receipt, jobId: 'other-job' }), 'Receipt cannot override its route-bound command target.');
|
||||
const history = control('control-commands-page.response.json');
|
||||
assert(!validControl('DeviceActionList', { ...history, commands: Array(201).fill(history.commands[0]) }));
|
||||
assert(!validControl('DeviceActionList', { ...history, nextCursor: 'x'.repeat(12001) }));
|
||||
const poll = control("control-poll.request.json");
|
||||
assert(!validControl("DeviceControlPollRequest", { ...poll, controlVersion: 2 }));
|
||||
assert(!validControl("DeviceControlPollRequest", { ...poll, capabilities: ["CANCEL_SPOOL", "CANCEL_SPOOL"] }));
|
||||
assert(!validControl("DeviceControlPollRequest", { ...poll, capabilities: ["RESTART_SERVICE"] }));
|
||||
assert(validControl("DeviceControlPollRequest", { ...poll, capabilities: ["REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1"] }), "Historical control poll accepts every non-realtime capability.");
|
||||
assert(!validControl("DeviceControlPollRequest", { ...poll, capabilities: ["REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1", "printer-approval-v1"] }));
|
||||
assert(!validControl("DeviceControlPollRequest", { ...poll, lastAppliedRevision: -1 }));
|
||||
assert(!validControl("DeviceControlPollRequest", { ...poll, lastAppliedRevision: 9007199254740992 }));
|
||||
const pollResult = /** @type {typeof import("../fixtures/control-poll.response.json")} */ (/** @type {unknown} */ (control("control-poll.response.json")));
|
||||
assert(validControl("DeviceControlPollResponse", { ...pollResult, futureResponseField: true }), "Additive response fields remain compatible.");
|
||||
assert(!validControl("DeviceControlPollResponse", { ...pollResult, commands: Array(21).fill(pollResult.commands[0]) }));
|
||||
assert(!validControl("DeviceControlPollResponse", { ...pollResult, desiredState: { ...pollResult.desiredState, connectionState: "REVOKED" } }), "Revocation is credential invalidation, not reversible desired state.");
|
||||
assert(!validControl("DeviceCommand", { ...pollResult.commands[1], jobId: null }));
|
||||
assert(!validControl("DeviceCommand", { ...pollResult.commands[0], jobId: "job-example-1" }));
|
||||
assert(!validControl("DeviceCommand", { ...pollResult.commands[0], action: "CLEAR_QUEUE" }), "Server queue clear is never an arbitrary client command.");
|
||||
const receipt = control("control-cancel-requested.receipt.json");
|
||||
assert(!validControl("DeviceCommandReceipt", { ...receipt, outcome: "PRINT_RECALLED" }));
|
||||
assert(!validControl("DeviceCommandReceipt", { ...receipt, message: "x".repeat(1025) }));
|
||||
assert(!validControl("DeviceCommandReceipt", { ...receipt, completedAt: "not-a-date" }));
|
||||
assert(!validControl("DeviceCommandReceipt", { ...receipt, jobId: "other-job" }), "Receipt cannot override its route-bound command target.");
|
||||
const history = /** @type {typeof import("../fixtures/control-commands-page.response.json")} */ (/** @type {unknown} */ (control("control-commands-page.response.json")));
|
||||
assert(!validControl("DeviceActionList", { ...history, commands: Array(201).fill(history.commands[0]) }));
|
||||
assert(!validControl("DeviceActionList", { ...history, nextCursor: "x".repeat(12001) }));
|
||||
console.log(`Compiled ${Object.keys(schema.$defs).length} JSON Schema 2020-12 definitions; ${Object.keys(fixtureTypes).length} fixtures passed full schema validation.`);
|
||||
|
||||
const media = control("printer-media-profile.response.json");
|
||||
for(const invalid of [{ dpiX: 0 }, { widthMicrons: 0 }, { revision: "not-a-revision" }, { applyMode: "CHANGE_STOCK" }])
|
||||
{
|
||||
assert(!validControl("PrinterMediaProfile", { ...media, ...invalid }));
|
||||
}
|
||||
const selection = /** @type {typeof import("../fixtures/media-profile-options.request.json")} */ (/** @type {unknown} */ (control("media-profile-options.request.json")));
|
||||
assert(!validControl("PrintOptions", { ...selection, paperWidthMm: 100, paperHeightMm: 150 }));
|
||||
assert(!validControl("PrinterMediaSelection", { ...selection.mediaProfile, futureOption: true }));
|
||||
|
||||
const operatorRequest = control("operator-submit.request.json");
|
||||
for(const invalid of [{ requestId: "x".repeat(129) }, { requestId: "echo/scan" }, { actorId: "forged" }, { tenantId: "forged" }])
|
||||
{
|
||||
assert(!validControl("OperatorSubmitJobRequest", { ...operatorRequest, ...invalid }));
|
||||
}
|
||||
assert(validControl("OperatorSubmitJobRequest", { ...operatorRequest, requestId: "x".repeat(128) }));
|
||||
|
||||
const catalogEntry = (/** @type {typeof import("../fixtures/operator-printers.response.json")} */ (/** @type {unknown} */ (control("operator-printers.response.json")))).printers[0];
|
||||
assert(validControl("OperatorPrinter", { ...catalogEntry, deviceName: "x".repeat(1024) }));
|
||||
assert(!validControl("OperatorPrinter", { ...catalogEntry, deviceName: "x".repeat(1025) }));
|
||||
assert(validControl("PrinterMediaProfile", { ...media, id: "x".repeat(128), futureMetadata: { supported: true } }));
|
||||
assert(!validControl("PrinterMediaProfile", { ...media, id: "x".repeat(129) }));
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": [
|
||||
"ES2024"
|
||||
],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"newLine": "lf",
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"noEmit": true,
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"tools/**/*.mjs",
|
||||
"eslint.config.mjs"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
"coverage"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
version = 1
|
||||
revision = 5
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "qqq-print-protocol-script-quality"
|
||||
version = "0.0.0"
|
||||
source = { virtual = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "ruff", specifier = "==0.17.3" }]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.17.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
|
||||
]
|
||||
Reference in new issue
Block a user