Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42f993378f | ||
|
|
e9fd15e7b6 | ||
|
|
d9e39702e0 | ||
|
|
7de633d4c9 |
+2
-1
@@ -3,7 +3,8 @@
|
|||||||
"browsers": [
|
"browsers": [
|
||||||
{
|
{
|
||||||
"brand": "Google Chrome",
|
"brand": "Google Chrome",
|
||||||
"fullVersion": "150.0.7871.187"
|
"fullVersion": "150.0.7871.187",
|
||||||
|
"supportedMajorVersions": [150, 151]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Microsoft Edge",
|
"brand": "Microsoft Edge",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const forbiddenDiagnosticPatterns = [
|
|||||||
/https?:\/\//i,
|
/https?:\/\//i,
|
||||||
];
|
];
|
||||||
const safePauseReasons = new Set([
|
const safePauseReasons = new Set([
|
||||||
"asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
|
"asset_manifest_invalid", "asset_root_missing", "asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
|
||||||
"contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed",
|
"contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed",
|
||||||
"model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown",
|
"model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown",
|
||||||
"worker_degraded", "worker_state_missing", "worker_stopped",
|
"worker_degraded", "worker_state_missing", "worker_stopped",
|
||||||
@@ -151,10 +151,13 @@ export function createAdminDiagnosticsProvider(input: {
|
|||||||
const system: AdminDiagnosticsResponse["system"] = {
|
const system: AdminDiagnosticsResponse["system"] = {
|
||||||
api_status: "ready",
|
api_status: "ready",
|
||||||
app_version: input.appVersion ?? input.browserSupportRelease?.appVersion ?? "0.0.0",
|
app_version: input.appVersion ?? input.browserSupportRelease?.appVersion ?? "0.0.0",
|
||||||
browser_support: (input.browserSupportRelease?.browsers ?? []).map((browser) => ({
|
browser_support: (input.browserSupportRelease?.browsers ?? []).flatMap((browser) => {
|
||||||
brand: browser.brand,
|
const majors = browser.supportedMajorVersions
|
||||||
major: Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10),
|
?? [Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10)];
|
||||||
})).filter((browser) => Number.isSafeInteger(browser.major) && browser.major > 0),
|
return majors
|
||||||
|
.map((major) => ({ brand: browser.brand, major }))
|
||||||
|
.filter((entry) => Number.isSafeInteger(entry.major) && entry.major > 0);
|
||||||
|
}),
|
||||||
worker_status: services.services.find((service) => service.service_id === "worker")?.status === "active"
|
worker_status: services.services.find((service) => service.service_id === "worker")?.status === "active"
|
||||||
? "ready"
|
? "ready"
|
||||||
: services.services.find((service) => service.service_id === "worker")?.status === "unavailable"
|
: services.services.find((service) => service.service_id === "worker")?.status === "unavailable"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const BrowserSupportSuccessSchema = Type.Object(
|
|||||||
app_version: Type.String({ maxLength: 80 }),
|
app_version: Type.String({ maxLength: 80 }),
|
||||||
browser: SupportedBrowserSummarySchema,
|
browser: SupportedBrowserSummarySchema,
|
||||||
status: Type.Literal("supported"),
|
status: Type.Literal("supported"),
|
||||||
supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 2 }),
|
supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 8 }),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false, $id: "BrowserSupportSuccess" },
|
{ additionalProperties: false, $id: "BrowserSupportSuccess" },
|
||||||
);
|
);
|
||||||
@@ -57,6 +57,7 @@ export interface BrowserSupportRelease {
|
|||||||
browsers: ReadonlyArray<{
|
browsers: ReadonlyArray<{
|
||||||
brand: SupportedBrand;
|
brand: SupportedBrand;
|
||||||
fullVersion: string;
|
fullVersion: string;
|
||||||
|
supportedMajorVersions?: ReadonlyArray<number>;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +116,14 @@ function supportedIdentity(entries: Array<{ brand: string; version: string }>) {
|
|||||||
|
|
||||||
export function supportedBrowserSummary(release: BrowserSupportRelease | undefined) {
|
export function supportedBrowserSummary(release: BrowserSupportRelease | undefined) {
|
||||||
if (!release) return [];
|
if (!release) return [];
|
||||||
return release.browsers.map(({ brand, fullVersion }) => ({ brand, major: major(fullVersion)! }));
|
return release.browsers.flatMap(({ brand, fullVersion, supportedMajorVersions }) => {
|
||||||
|
const majors = supportedMajorVersions ?? [major(fullVersion)!];
|
||||||
|
return majors.map((supportedMajor) => ({ brand, major: supportedMajor }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptedMajorVersions(browser: BrowserSupportRelease["browsers"][number]) {
|
||||||
|
return browser.supportedMajorVersions ?? [major(browser.fullVersion)!];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateBrowserSupportRelease(value: unknown): value is BrowserSupportRelease {
|
export function validateBrowserSupportRelease(value: unknown): value is BrowserSupportRelease {
|
||||||
@@ -126,13 +134,26 @@ export function validateBrowserSupportRelease(value: unknown): value is BrowserS
|
|||||||
}
|
}
|
||||||
if (!Array.isArray(release.browsers) || release.browsers.length !== 2) return false;
|
if (!Array.isArray(release.browsers) || release.browsers.length !== 2) return false;
|
||||||
const brands = new Set(release.browsers.map(({ brand }) => brand));
|
const brands = new Set(release.browsers.map(({ brand }) => brand));
|
||||||
|
const supportedMajorCount = release.browsers.reduce(
|
||||||
|
(count, browser) => count + (browser.supportedMajorVersions?.length ?? 1),
|
||||||
|
0,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
brands.size === 2 &&
|
brands.size === 2 &&
|
||||||
brands.has("Google Chrome") &&
|
brands.has("Google Chrome") &&
|
||||||
brands.has("Microsoft Edge") &&
|
brands.has("Microsoft Edge") &&
|
||||||
release.browsers.every(
|
supportedMajorCount <= 8 &&
|
||||||
({ brand, fullVersion }) => supportedBrands.has(brand) && fullVersionPattern.test(fullVersion),
|
release.browsers.every(({ brand, fullVersion, supportedMajorVersions }) => {
|
||||||
)
|
if (!supportedBrands.has(brand) || !fullVersionPattern.test(fullVersion)) return false;
|
||||||
|
const baselineMajor = major(fullVersion);
|
||||||
|
if (!baselineMajor) return false;
|
||||||
|
if (supportedMajorVersions === undefined) return true;
|
||||||
|
return supportedMajorVersions.length > 0
|
||||||
|
&& supportedMajorVersions.length <= 8
|
||||||
|
&& supportedMajorVersions.every((value: number) => Number.isSafeInteger(value) && value >= 1)
|
||||||
|
&& new Set(supportedMajorVersions).size === supportedMajorVersions.length
|
||||||
|
&& supportedMajorVersions.includes(baselineMajor);
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +209,7 @@ export function checkBrowserSupport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const supported = release?.browsers.find(({ brand }) => brand === fullIdentity.brand);
|
const supported = release?.browsers.find(({ brand }) => brand === fullIdentity.brand);
|
||||||
if (!supported || major(supported.fullVersion) !== fullIdentity.major) {
|
if (!supported || !acceptedMajorVersions(supported).includes(fullIdentity.major)) {
|
||||||
return { reason: "version_unsupported", supported: false };
|
return { reason: "version_unsupported", supported: false };
|
||||||
}
|
}
|
||||||
return { identity: fullIdentity, supported: true };
|
return { identity: fullIdentity, supported: true };
|
||||||
@@ -268,7 +289,7 @@ export function verifyBrowserSupportCookie(input: {
|
|||||||
return { reason: "identity_unavailable" as const, supported: false as const };
|
return { reason: "identity_unavailable" as const, supported: false as const };
|
||||||
}
|
}
|
||||||
const supported = input.release.browsers.find(({ brand }) => brand === currentIdentity.brand);
|
const supported = input.release.browsers.find(({ brand }) => brand === currentIdentity.brand);
|
||||||
if (currentIdentity.major !== payload.major || major(supported?.fullVersion ?? "") !== currentIdentity.major) {
|
if (!supported || currentIdentity.major !== payload.major || !acceptedMajorVersions(supported).includes(currentIdentity.major)) {
|
||||||
return { reason: "version_unsupported" as const, supported: false as const };
|
return { reason: "version_unsupported" as const, supported: false as const };
|
||||||
}
|
}
|
||||||
return { identity: currentIdentity, supported: true as const };
|
return { identity: currentIdentity, supported: true as const };
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:p
|
|||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const Database = require("better-sqlite3") as typeof import("better-sqlite3");
|
const Database = require("better-sqlite3") as typeof import("better-sqlite3");
|
||||||
|
|
||||||
const assetIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
|
||||||
const fixedDirectories = [
|
const fixedDirectories = [
|
||||||
"db",
|
"db",
|
||||||
"content/references",
|
"content/references",
|
||||||
@@ -82,13 +82,21 @@ export function readConfiguredLocalDataRoot(configFile = defaultInstanceConfigPa
|
|||||||
return resolve(candidate);
|
return resolve(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readConfiguredAssetRoot(configFile = defaultInstanceConfigPath()) {
|
||||||
|
const configuration = JSON.parse(readFileSync(configFile, "utf8")) as Record<string, unknown>;
|
||||||
|
if (typeof configuration.asset_root !== "string" || !isAbsolute(configuration.asset_root)) {
|
||||||
|
throw new Error("asset_root_configuration_invalid");
|
||||||
|
}
|
||||||
|
return resolve(configuration.asset_root);
|
||||||
|
}
|
||||||
|
|
||||||
export interface ValidatedReadOnlyAssetRoot {
|
export interface ValidatedReadOnlyAssetRoot {
|
||||||
absolute_root: string;
|
absolute_root: string;
|
||||||
ok: true;
|
ok: true;
|
||||||
root_ref: string;
|
root_ref: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicAssetEntry {
|
export interface PublicAssetEntry {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
@@ -317,18 +325,19 @@ export function createPublicAssetResolver(input: {
|
|||||||
const roots = new Map(input.roots.map((root) => [root.root_ref, root.absolute_root]));
|
const roots = new Map(input.roots.map((root) => [root.root_ref, root.absolute_root]));
|
||||||
const entries = new Map<string, PublicAssetEntry>();
|
const entries = new Map<string, PublicAssetEntry>();
|
||||||
for (const entry of input.entries) {
|
for (const entry of input.entries) {
|
||||||
if (!assetIdPattern.test(entry.assetId) || entries.has(entry.assetId)) throw new Error("asset_id_invalid");
|
const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
|
||||||
|
if (!assetIdPattern.test(entry.assetId) || entries.has(key)) throw new Error("asset_id_invalid");
|
||||||
if (!roots.has(entry.rootRef)) throw new Error("asset_root_unvalidated");
|
if (!roots.has(entry.rootRef)) throw new Error("asset_root_unvalidated");
|
||||||
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(entry.resourceVersion)) throw new Error("resource_version_invalid");
|
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(entry.resourceVersion)) throw new Error("resource_version_invalid");
|
||||||
if (!/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)) throw new Error("mime_type_invalid");
|
if (!/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)) throw new Error("mime_type_invalid");
|
||||||
entries.set(entry.assetId, { ...entry });
|
entries.set(key, { ...entry });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
read(resourceVersion, assetId) {
|
read(resourceVersion, assetId) {
|
||||||
if (!assetIdPattern.test(assetId)) return undefined;
|
if (!assetIdPattern.test(assetId)) return undefined;
|
||||||
const entry = entries.get(assetId);
|
const entry = entries.get(`${resourceVersion}\u0000${assetId}`);
|
||||||
if (!entry || entry.resourceVersion !== resourceVersion) return undefined;
|
if (!entry) return undefined;
|
||||||
const root = roots.get(entry.rootRef);
|
const root = roots.get(entry.rootRef);
|
||||||
if (!root) return undefined;
|
if (!root) return undefined;
|
||||||
let path: string;
|
let path: string;
|
||||||
|
|||||||
+13
-1
@@ -5,7 +5,7 @@ import { registrationNotice } from "@dada/shared-contracts";
|
|||||||
|
|
||||||
import { createApp } from "./app.js";
|
import { createApp } from "./app.js";
|
||||||
import { readBrowserSupportRelease } from "./browser-support.js";
|
import { readBrowserSupportRelease } from "./browser-support.js";
|
||||||
import { defaultInstanceConfigPath, ensureLocalDataRuntimeDirectories, readConfiguredLocalDataRoot } from "./local-data-root.js";
|
import { defaultInstanceConfigPath, ensureLocalDataRuntimeDirectories, readConfiguredLocalDataRoot, type PublicAssetResolver } from "./local-data-root.js";
|
||||||
import { ManagedStorage } from "./managed-storage.js";
|
import { ManagedStorage } from "./managed-storage.js";
|
||||||
import { LatestExportService } from "./latest-exports.js";
|
import { LatestExportService } from "./latest-exports.js";
|
||||||
import { CreditService } from "./credits.js";
|
import { CreditService } from "./credits.js";
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js";
|
import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js";
|
||||||
import { StickerReleaseService } from "./sticker-releases.js";
|
import { StickerReleaseService } from "./sticker-releases.js";
|
||||||
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
|
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
|
||||||
|
import { loadConfiguredRuntimeAssets, type RuntimeAssetState } from "./runtime-assets.js";
|
||||||
|
|
||||||
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
||||||
let registration: RegistrationService | undefined;
|
let registration: RegistrationService | undefined;
|
||||||
@@ -36,6 +37,8 @@ let models: ModelConfigurationService | undefined;
|
|||||||
let generations: GenerationSubmissionService | undefined;
|
let generations: GenerationSubmissionService | undefined;
|
||||||
let recentAssets: RecentAssetService | undefined;
|
let recentAssets: RecentAssetService | undefined;
|
||||||
let stickers: StickerReleaseService | undefined;
|
let stickers: StickerReleaseService | undefined;
|
||||||
|
let publicAssets: PublicAssetResolver | undefined;
|
||||||
|
let assetRootState: RuntimeAssetState | undefined;
|
||||||
let amap: AmapAdapter = new MockAmapAdapter();
|
let amap: AmapAdapter = new MockAmapAdapter();
|
||||||
let localTestAuth = false;
|
let localTestAuth = false;
|
||||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||||
@@ -49,6 +52,13 @@ if (credentialChannelEnabled) {
|
|||||||
.digest();
|
.digest();
|
||||||
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
||||||
ensureLocalDataRuntimeDirectories(dataRoot);
|
ensureLocalDataRuntimeDirectories(dataRoot);
|
||||||
|
const runtimeAssets = loadConfiguredRuntimeAssets({
|
||||||
|
configFile: instanceConfigPath,
|
||||||
|
dataRoot,
|
||||||
|
trustedManifestPath: resolve("asset-metadata", "manifest.json"),
|
||||||
|
});
|
||||||
|
publicAssets = runtimeAssets.publicAssets;
|
||||||
|
assetRootState = runtimeAssets.state;
|
||||||
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
||||||
registration = new RegistrationService({
|
registration = new RegistrationService({
|
||||||
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
|
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
|
||||||
@@ -101,6 +111,7 @@ const adminServicesStorage = registration
|
|||||||
database: registration.database,
|
database: registration.database,
|
||||||
...(models ? { models } : {}),
|
...(models ? { models } : {}),
|
||||||
...(storage ? { storage } : {}),
|
...(storage ? { storage } : {}),
|
||||||
|
...(assetRootState ? { assetRoot: assetRootState } : {}),
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
const adminDiagnostics = adminServicesStorage
|
const adminDiagnostics = adminServicesStorage
|
||||||
@@ -120,6 +131,7 @@ const app = await createApp({
|
|||||||
...(registration && localTestAuth ? { localTestAuth: true } : {}),
|
...(registration && localTestAuth ? { localTestAuth: true } : {}),
|
||||||
...(models ? { models } : {}),
|
...(models ? { models } : {}),
|
||||||
...(projects ? { projects } : {}),
|
...(projects ? { projects } : {}),
|
||||||
|
...(publicAssets ? { publicAssets } : {}),
|
||||||
...(registration ? { registration } : {}),
|
...(registration ? { registration } : {}),
|
||||||
...(recentAssets ? { recentAssets } : {}),
|
...(recentAssets ? { recentAssets } : {}),
|
||||||
...(stickers ? { stickers } : {}),
|
...(stickers ? { stickers } : {}),
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createPublicAssetResolver,
|
||||||
|
readConfiguredAssetRoot,
|
||||||
|
validateReadOnlyAssetRoot,
|
||||||
|
type PublicAssetEntry,
|
||||||
|
type PublicAssetResolver,
|
||||||
|
} from "./local-data-root.js";
|
||||||
|
|
||||||
|
const rootRef = "p0a_runtime_assets";
|
||||||
|
const schemaVersion = "DadaRuntimeAssets/v1";
|
||||||
|
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
|
||||||
|
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
|
||||||
|
const shaPattern = /^[a-f0-9]{64}$/i;
|
||||||
|
|
||||||
|
export interface RuntimeAssetState {
|
||||||
|
checked_at: string;
|
||||||
|
configured: boolean;
|
||||||
|
pause_reason: "asset_manifest_invalid" | "asset_root_missing" | "asset_root_state_missing" | null;
|
||||||
|
status: "active" | "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadedRuntimeAssets {
|
||||||
|
publicAssets?: PublicAssetResolver;
|
||||||
|
state: RuntimeAssetState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRuntimeManifest(bytes: Buffer): PublicAssetEntry[] {
|
||||||
|
const value = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
|
||||||
|
if (value.schema_version !== schemaVersion || value.source !== "external_read_only" || value.root_ref !== rootRef) {
|
||||||
|
throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value.entries) || value.entries.length === 0) throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
return value.entries.map((candidate) => {
|
||||||
|
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
const entry = candidate as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof entry.assetId !== "string" || !assetIdPattern.test(entry.assetId)
|
||||||
|
|| typeof entry.mimeType !== "string" || !/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)
|
||||||
|
|| typeof entry.relativePath !== "string" || entry.relativePath.includes("\\") || entry.relativePath.split("/").includes("..")
|
||||||
|
|| typeof entry.resourceVersion !== "string" || !releasePattern.test(entry.resourceVersion)
|
||||||
|
|| entry.rootRef !== rootRef
|
||||||
|
|| typeof entry.sha256 !== "string" || !shaPattern.test(entry.sha256)
|
||||||
|
) throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
return entry as unknown as PublicAssetEntry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailable(
|
||||||
|
configured: boolean,
|
||||||
|
pauseReason: Exclude<RuntimeAssetState["pause_reason"], null>,
|
||||||
|
checkedAt: string,
|
||||||
|
): LoadedRuntimeAssets {
|
||||||
|
return { state: { checked_at: checkedAt, configured, pause_reason: pauseReason, status: "unavailable" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfiguredRuntimeAssets(input: {
|
||||||
|
configFile: string;
|
||||||
|
dataRoot: string;
|
||||||
|
trustedManifestPath: string;
|
||||||
|
clock?: () => number;
|
||||||
|
}): LoadedRuntimeAssets {
|
||||||
|
const checkedAt = new Date((input.clock ?? Date.now)()).toISOString();
|
||||||
|
let assetRoot: string;
|
||||||
|
try {
|
||||||
|
assetRoot = readConfiguredAssetRoot(input.configFile);
|
||||||
|
} catch {
|
||||||
|
return unavailable(false, "asset_root_state_missing", checkedAt);
|
||||||
|
}
|
||||||
|
if (!existsSync(input.trustedManifestPath)) return unavailable(true, "asset_manifest_invalid", checkedAt);
|
||||||
|
try {
|
||||||
|
const trustedBytes = readFileSync(input.trustedManifestPath);
|
||||||
|
const entries = parseRuntimeManifest(trustedBytes);
|
||||||
|
const validatedRoot = validateReadOnlyAssetRoot({
|
||||||
|
dataRoot: input.dataRoot,
|
||||||
|
expectedSha256: createHash("sha256").update(trustedBytes).digest("hex"),
|
||||||
|
manifestRelativePath: "manifest.json",
|
||||||
|
root: assetRoot,
|
||||||
|
rootRef,
|
||||||
|
});
|
||||||
|
if (!validatedRoot.ok) {
|
||||||
|
return unavailable(true, validatedRoot.reason === "asset_root_missing" ? "asset_root_missing" : "asset_manifest_invalid", checkedAt);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
publicAssets: createPublicAssetResolver({ entries, roots: [validatedRoot] }),
|
||||||
|
state: { checked_at: checkedAt, configured: true, pause_reason: null, status: "active" },
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return unavailable(true, "asset_manifest_invalid", checkedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export interface GenerationPollingProcessor {
|
||||||
|
processNext(): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerationPollingLoop {
|
||||||
|
private closed = false;
|
||||||
|
private inFlight = false;
|
||||||
|
private readonly timer: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly processor: GenerationPollingProcessor,
|
||||||
|
intervalMilliseconds = 250,
|
||||||
|
) {
|
||||||
|
if (!Number.isSafeInteger(intervalMilliseconds) || intervalMilliseconds <= 0) {
|
||||||
|
throw new Error("generation_polling_interval_invalid");
|
||||||
|
}
|
||||||
|
this.timer = setInterval(() => this.run(), intervalMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private run() {
|
||||||
|
if (this.closed || this.inFlight) return;
|
||||||
|
this.inFlight = true;
|
||||||
|
void this.processor.processNext()
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
this.inFlight = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ const gptImageEndpoint = "https://oneapi.intelligrow.cn/v1/images/generations";
|
|||||||
const gptImageReferenceEndpoint = "https://oneapi.intelligrow.cn/v1/images/edits";
|
const gptImageReferenceEndpoint = "https://oneapi.intelligrow.cn/v1/images/edits";
|
||||||
const maximumResponseBytes = 32 * 1024 * 1024;
|
const maximumResponseBytes = 32 * 1024 * 1024;
|
||||||
const requestTimeoutMilliseconds = 180_000;
|
const requestTimeoutMilliseconds = 180_000;
|
||||||
|
const geminiImageSystemInstruction = "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.";
|
||||||
|
|
||||||
type FetchLike = typeof fetch;
|
type FetchLike = typeof fetch;
|
||||||
|
|
||||||
@@ -141,7 +142,10 @@ function buildRequest(request: GenerationAdapterRequest) {
|
|||||||
return {
|
return {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
extra_body: { google: { image_config: { aspect_ratio: request.ratio, image_size: "1K" } } },
|
extra_body: { google: { image_config: { aspect_ratio: request.ratio, image_size: "1K" } } },
|
||||||
messages: [{ content, role: "user" }],
|
messages: [
|
||||||
|
{ content: geminiImageSystemInstruction, role: "system" },
|
||||||
|
{ content, role: "user" },
|
||||||
|
],
|
||||||
model: geminiProviderModelId,
|
model: geminiProviderModelId,
|
||||||
stream: false,
|
stream: false,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
||||||
import { runAiRuntimeProbe } from "./ai-runtime-probe.js";
|
import { runAiRuntimeProbe } from "./ai-runtime-probe.js";
|
||||||
|
import { GenerationPollingLoop } from "./generation-polling-loop.js";
|
||||||
import { GenerationProcessor } from "./generation-processor.js";
|
import { GenerationProcessor } from "./generation-processor.js";
|
||||||
import { OneApiGenerationAdapter } from "./oneapi-generation-adapter.js";
|
import { OneApiGenerationAdapter } from "./oneapi-generation-adapter.js";
|
||||||
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
||||||
@@ -52,13 +53,13 @@ if (!workerPort && process.argv.includes("--dada-ai-probe")) {
|
|||||||
let projectCleanup: ProjectPurgeCleanup | undefined;
|
let projectCleanup: ProjectPurgeCleanup | undefined;
|
||||||
let retentionTimer: ReturnType<typeof setInterval> | undefined;
|
let retentionTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
let processor: GenerationProcessor | undefined;
|
let processor: GenerationProcessor | undefined;
|
||||||
let generationTimer: ReturnType<typeof setInterval> | undefined;
|
let generationLoop: GenerationPollingLoop | undefined;
|
||||||
const control = attachWorkerSupervisorControl(controlPipe, () => {
|
const control = attachWorkerSupervisorControl(controlPipe, () => {
|
||||||
clearInterval(keepAlive);
|
clearInterval(keepAlive);
|
||||||
if (retentionTimer) clearInterval(retentionTimer);
|
if (retentionTimer) clearInterval(retentionTimer);
|
||||||
retention?.close();
|
retention?.close();
|
||||||
projectCleanup?.close();
|
projectCleanup?.close();
|
||||||
if (generationTimer) clearInterval(generationTimer);
|
generationLoop?.close();
|
||||||
processor?.close();
|
processor?.close();
|
||||||
storage?.close();
|
storage?.close();
|
||||||
});
|
});
|
||||||
@@ -102,9 +103,11 @@ if (!workerPort && process.argv.includes("--dada-ai-probe")) {
|
|||||||
});
|
});
|
||||||
logger.write({ error_category: "none", status_category: "ready" });
|
logger.write({ error_category: "none", status_category: "ready" });
|
||||||
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
|
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
|
||||||
generationTimer = setInterval(() => { void processor?.processNext().catch(() => undefined); }, 250);
|
generationLoop = new GenerationPollingLoop(processor);
|
||||||
} catch {
|
} catch {
|
||||||
storageStatus = "unavailable";
|
storageStatus = "unavailable";
|
||||||
control.reportStatus("storage_unavailable");
|
control.reportStatus("storage_unavailable");
|
||||||
|
clearInterval(keepAlive);
|
||||||
|
setTimeout(() => process.exit(1), 50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,8 @@
|
|||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||||
"test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
|
"test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
|
||||||
"package:portable": "node scripts/build-portable.mjs",
|
"package:portable": "node scripts/build-portable.mjs",
|
||||||
|
"assets:manifest": "pnpm build:workspace-packages && node scripts/generate-runtime-asset-manifest.mjs",
|
||||||
|
"assets:deploy": "pnpm build:workspace-packages && node scripts/deploy-runtime-assets.mjs",
|
||||||
"generate:openapi": "node scripts/generate-openapi.mjs",
|
"generate:openapi": "node scripts/generate-openapi.mjs",
|
||||||
"check:openapi": "node scripts/check-openapi.mjs",
|
"check:openapi": "node scripts/check-openapi.mjs",
|
||||||
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ export const AdminDiagnosticsResponseSchema = Type.Object({
|
|||||||
browser_support: Type.Array(Type.Object({
|
browser_support: Type.Array(Type.Object({
|
||||||
brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]),
|
brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]),
|
||||||
major: Type.Integer({ minimum: 1 }),
|
major: Type.Integer({ minimum: 1 }),
|
||||||
}, { additionalProperties: false }), { maxItems: 2 }),
|
}, { additionalProperties: false }), { maxItems: 8 }),
|
||||||
worker_status: Type.Union([Type.Literal("ready"), Type.Literal("degraded"), Type.Literal("unavailable")]),
|
worker_status: Type.Union([Type.Literal("ready"), Type.Literal("degraded"), Type.Literal("unavailable")]),
|
||||||
}, { additionalProperties: false }),
|
}, { additionalProperties: false }),
|
||||||
}, { additionalProperties: false, $id: "AdminDiagnosticsResponse" });
|
}, { additionalProperties: false, $id: "AdminDiagnosticsResponse" });
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const ErrorDetailsSchema = Type.Object(
|
|||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
{ maxItems: 2 },
|
{ maxItems: 8 },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
capacity_status: Type.Optional(
|
capacity_status: Type.Optional(
|
||||||
|
|||||||
@@ -23,6 +23,27 @@ export const P0A_DYNAMIC_STICKER_IDS = [
|
|||||||
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
|
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const P0A_DYNAMIC_RUNTIME_FONT_SOURCES = [
|
||||||
|
{ assetId: "15974853bc3294ef68e7e6d58fe74fd7", sourceReference: "fonts/15974853bc3294ef68e7e6d58fe74fd7", templateId: "DYN002" },
|
||||||
|
{ assetId: "46f8336813e4c48d06a1aef294fdccf6", sourceReference: "fonts/46f8336813e4c48d06a1aef294fdccf6", templateId: "DYN016" },
|
||||||
|
{ assetId: "53ca6b704728520da50c145eabb2e635", sourceReference: "fonts/53ca6b704728520da50c145eabb2e635", templateId: "DYN007" },
|
||||||
|
{ assetId: "cca5efc0e02fb1bf62349bd68ef30fc1", sourceReference: "fonts/cca5efc0e02fb1bf62349bd68ef30fc1", templateId: "DYN015" },
|
||||||
|
{ assetId: "dd25b35dcb7ba4476cbaa9a9592e39e2", sourceReference: "fonts/dd25b35dcb7ba4476cbaa9a9592e39e2", templateId: "DYN001" },
|
||||||
|
{ assetId: "e4210c9872f0c279b35273f230809821", sourceReference: "fonts/e4210c9872f0c279b35273f230809821", templateId: "DYN011" },
|
||||||
|
{ assetId: "f4bfd4132df2d6be97ceabadf3853505", sourceReference: "fonts/f4bfd4132df2d6be97ceabadf3853505", templateId: "DYN008" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES = [
|
||||||
|
{ assetId: "DYN001-image28", sourceReference: "resource/image28.png", templateId: "DYN001" },
|
||||||
|
{ assetId: "DYN002-image29", sourceReference: "resource/image29.png", templateId: "DYN002" },
|
||||||
|
{ assetId: "DYN003-image30", sourceReference: "resource/image30.png", templateId: "DYN003" },
|
||||||
|
{ assetId: "DYN004-image32", sourceReference: "resource/image32.png", templateId: "DYN004" },
|
||||||
|
{ assetId: "DYN008-backendui0", sourceReference: "resource/backendui0.png", templateId: "DYN008" },
|
||||||
|
{ assetId: "DYN011-backendui0", sourceReference: "resource/backendui0.png", templateId: "DYN011" },
|
||||||
|
{ assetId: "DYN015-imager2", sourceReference: "resource/imager2_2.png", templateId: "DYN015" },
|
||||||
|
{ assetId: "DYN016-image21", sourceReference: "resource/image21.png", templateId: "DYN016" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type RegisteredComplexFamily = "color_card" | "font_panel" | "interactive_sticker" | "text_template";
|
export type RegisteredComplexFamily = "color_card" | "font_panel" | "interactive_sticker" | "text_template";
|
||||||
|
|
||||||
export interface RegisteredComplexAsset extends Record<string, unknown> {
|
export interface RegisteredComplexAsset extends Record<string, unknown> {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { isAbsolute, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildP0aRuntimeAssetPlan,
|
||||||
|
defaultReplicationRoot,
|
||||||
|
deployRuntimeAssetPlan,
|
||||||
|
readRuntimeAssetManifest,
|
||||||
|
serializeRuntimeAssetManifest,
|
||||||
|
} from "./lib/runtime-assets.mjs";
|
||||||
|
|
||||||
|
function option(name) {
|
||||||
|
const index = process.argv.indexOf(name);
|
||||||
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultConfigPath() {
|
||||||
|
if (!process.env.LOCALAPPDATA || !isAbsolute(process.env.LOCALAPPDATA)) throw new Error("local_app_data_unavailable");
|
||||||
|
return join(process.env.LOCALAPPDATA, "Dada", "P0A", "config", "instance.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFile = resolve(option("--config") ?? process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultConfigPath());
|
||||||
|
const configuration = JSON.parse(readFileSync(configFile, "utf8"));
|
||||||
|
const assetRootCandidate = option("--asset-root") ?? configuration.asset_root;
|
||||||
|
if (typeof assetRootCandidate !== "string" || !isAbsolute(assetRootCandidate)) {
|
||||||
|
throw new Error("asset_root_configuration_invalid");
|
||||||
|
}
|
||||||
|
const assetRoot = resolve(assetRootCandidate);
|
||||||
|
const trustedManifest = readRuntimeAssetManifest(resolve(option("--trusted-manifest") ?? "config/runtime-assets-manifest.json"));
|
||||||
|
const plan = await buildP0aRuntimeAssetPlan({
|
||||||
|
replicationRoot: resolve(option("--replication-root") ?? defaultReplicationRoot()),
|
||||||
|
});
|
||||||
|
if (serializeRuntimeAssetManifest(plan.manifest) !== serializeRuntimeAssetManifest(trustedManifest)) {
|
||||||
|
throw new Error("runtime_asset_source_does_not_match_trusted_manifest");
|
||||||
|
}
|
||||||
|
const result = deployRuntimeAssetPlan({ assetRoot, manifest: trustedManifest, resources: plan.resources });
|
||||||
|
process.stdout.write(`${JSON.stringify({ linked_files: result.linked_files, status: result.status })}\n`);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildP0aRuntimeAssetPlan,
|
||||||
|
defaultReplicationRoot,
|
||||||
|
serializeRuntimeAssetManifest,
|
||||||
|
writeRuntimeAssetManifest,
|
||||||
|
} from "./lib/runtime-assets.mjs";
|
||||||
|
|
||||||
|
function option(name) {
|
||||||
|
const index = process.argv.indexOf(name);
|
||||||
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replicationRoot = resolve(option("--replication-root") ?? defaultReplicationRoot());
|
||||||
|
const outputPath = resolve(option("--output") ?? "config/runtime-assets-manifest.json");
|
||||||
|
const plan = await buildP0aRuntimeAssetPlan({ replicationRoot });
|
||||||
|
writeRuntimeAssetManifest(outputPath, plan.manifest);
|
||||||
|
process.stdout.write(`${JSON.stringify({
|
||||||
|
counts: plan.manifest.counts,
|
||||||
|
manifest_bytes: Buffer.byteLength(serializeRuntimeAssetManifest(plan.manifest)),
|
||||||
|
status: "generated",
|
||||||
|
})}\n`);
|
||||||
@@ -18,6 +18,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
||||||
|
|
||||||
import { frozenRuntime } from "../frozen-versions.mjs";
|
import { frozenRuntime } from "../frozen-versions.mjs";
|
||||||
|
import { readRuntimeAssetManifest } from "./runtime-assets.mjs";
|
||||||
|
|
||||||
const repositoryRoot = resolve(import.meta.dirname, "..", "..");
|
const repositoryRoot = resolve(import.meta.dirname, "..", "..");
|
||||||
const fixedPort = 43121;
|
const fixedPort = 43121;
|
||||||
@@ -363,7 +364,10 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
|
|||||||
copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web"));
|
copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web"));
|
||||||
copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate"));
|
copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate"));
|
||||||
writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" });
|
writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" });
|
||||||
writeJson(join(packageDirectory, "asset-metadata", "manifest.json"), { resources: [], schema_version: "1.0", source: "external_read_only" });
|
writeJson(
|
||||||
|
join(packageDirectory, "asset-metadata", "manifest.json"),
|
||||||
|
readRuntimeAssetManifest(join(repositoryRoot, "config", "runtime-assets-manifest.json")),
|
||||||
|
);
|
||||||
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
|
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
|
||||||
|
|
||||||
const commit = run("git", ["rev-parse", "HEAD"]);
|
const commit = run("git", ["rev-parse", "HEAD"]);
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
linkSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
realpathSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
|
||||||
|
|
||||||
|
export const P0A_RUNTIME_ASSET_ROOT_REF = "p0a_runtime_assets";
|
||||||
|
export const RUNTIME_ASSET_MANIFEST_SCHEMA = "DadaRuntimeAssets/v1";
|
||||||
|
|
||||||
|
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
|
||||||
|
const mimePattern = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i;
|
||||||
|
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
|
||||||
|
const shaPattern = /^[a-f0-9]{64}$/i;
|
||||||
|
const fontMimeTypes = new Map([
|
||||||
|
[".otf", "font/otf"],
|
||||||
|
[".ttf", "font/ttf"],
|
||||||
|
[".woff", "font/woff"],
|
||||||
|
[".woff2", "font/woff2"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sha256(bytes) {
|
||||||
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileSha256(path) {
|
||||||
|
return sha256(readFileSync(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableEntries(entries) {
|
||||||
|
return entries.map((entry) => {
|
||||||
|
if (!entry || typeof entry !== "object") throw new Error("runtime_asset_entry_invalid");
|
||||||
|
if (!assetIdPattern.test(entry.assetId)) throw new Error("runtime_asset_id_invalid");
|
||||||
|
if (!mimePattern.test(entry.mimeType)) throw new Error("runtime_asset_mime_invalid");
|
||||||
|
if (!releasePattern.test(entry.resourceVersion)) throw new Error("runtime_asset_version_invalid");
|
||||||
|
if (entry.rootRef !== P0A_RUNTIME_ASSET_ROOT_REF) throw new Error("runtime_asset_root_ref_invalid");
|
||||||
|
if (!shaPattern.test(entry.sha256)) throw new Error("runtime_asset_sha256_invalid");
|
||||||
|
if (
|
||||||
|
typeof entry.relativePath !== "string"
|
||||||
|
|| isAbsolute(entry.relativePath)
|
||||||
|
|| entry.relativePath.includes("\\")
|
||||||
|
|| entry.relativePath.split("/").some((part) => part === "" || part === "..")
|
||||||
|
) throw new Error("runtime_asset_relative_path_invalid");
|
||||||
|
return { ...entry, sha256: entry.sha256.toLowerCase() };
|
||||||
|
}).sort((left, right) => {
|
||||||
|
const byVersion = left.resourceVersion.localeCompare(right.resourceVersion);
|
||||||
|
return byVersion || left.assetId.localeCompare(right.assetId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function derivedCounts(entries) {
|
||||||
|
return {
|
||||||
|
dynamic_fonts: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^[a-f0-9]{32}$/.test(entry.assetId)).length,
|
||||||
|
dynamic_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^DYN\d{3}-/.test(entry.assetId)).length,
|
||||||
|
font_panel_items: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^FONT\d{3}$/.test(entry.assetId)).length,
|
||||||
|
static_stickers: entries.filter((entry) => entry.resourceVersion === "p0a-static-v1" && /^STK\d{3,4}$/.test(entry.assetId)).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRuntimeAssetManifest({ counts, entries, sourceManifestSha256 }) {
|
||||||
|
const normalizedEntries = stableEntries(entries);
|
||||||
|
const keys = new Set();
|
||||||
|
const paths = new Set();
|
||||||
|
for (const entry of normalizedEntries) {
|
||||||
|
const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
|
||||||
|
if (keys.has(key)) throw new Error("runtime_asset_id_duplicate");
|
||||||
|
if (paths.has(entry.relativePath)) throw new Error("runtime_asset_path_duplicate");
|
||||||
|
keys.add(key);
|
||||||
|
paths.add(entry.relativePath);
|
||||||
|
}
|
||||||
|
const actualCounts = derivedCounts(normalizedEntries);
|
||||||
|
if (JSON.stringify(counts) !== JSON.stringify(actualCounts)) throw new Error("runtime_asset_counts_invalid");
|
||||||
|
if (sourceManifestSha256 !== undefined && !shaPattern.test(sourceManifestSha256)) {
|
||||||
|
throw new Error("runtime_asset_source_manifest_sha256_invalid");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
counts: actualCounts,
|
||||||
|
entries: normalizedEntries,
|
||||||
|
root_ref: P0A_RUNTIME_ASSET_ROOT_REF,
|
||||||
|
schema_version: RUNTIME_ASSET_MANIFEST_SCHEMA,
|
||||||
|
source: "external_read_only",
|
||||||
|
...(sourceManifestSha256 ? { source_manifest_sha256: sourceManifestSha256.toLowerCase() } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRuntimeAssetManifest(path) {
|
||||||
|
const value = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
if (
|
||||||
|
value?.schema_version !== RUNTIME_ASSET_MANIFEST_SCHEMA
|
||||||
|
|| value?.source !== "external_read_only"
|
||||||
|
|| value?.root_ref !== P0A_RUNTIME_ASSET_ROOT_REF
|
||||||
|
|| !Array.isArray(value.entries)
|
||||||
|
) throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
return createRuntimeAssetManifest({
|
||||||
|
counts: value.counts,
|
||||||
|
entries: value.entries,
|
||||||
|
...(value.source_manifest_sha256 ? { sourceManifestSha256: value.source_manifest_sha256 } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeRuntimeAssetManifest(manifest) {
|
||||||
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeRuntimeAssetManifest(path, manifest) {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
writeFileSync(path, serializeRuntimeAssetManifest(manifest));
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetWithinRoot(root, relativePath) {
|
||||||
|
const absoluteRoot = resolve(root);
|
||||||
|
const target = resolve(absoluteRoot, ...relativePath.split("/"));
|
||||||
|
if (target === absoluteRoot || !target.startsWith(`${absoluteRoot}${sep}`)) throw new Error("asset_target_path_invalid");
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameFile(left, right) {
|
||||||
|
const leftStat = statSync(left);
|
||||||
|
const rightStat = statSync(right);
|
||||||
|
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deployRuntimeAssetPlan({ assetRoot, manifest, resources }) {
|
||||||
|
if (!isAbsolute(assetRoot)) throw new Error("asset_root_must_be_absolute");
|
||||||
|
const normalizedManifest = createRuntimeAssetManifest({
|
||||||
|
counts: manifest.counts,
|
||||||
|
entries: manifest.entries,
|
||||||
|
...(manifest.source_manifest_sha256 ? { sourceManifestSha256: manifest.source_manifest_sha256 } : {}),
|
||||||
|
});
|
||||||
|
const entries = new Map(normalizedManifest.entries.map((entry) => [`${entry.resourceVersion}\u0000${entry.assetId}`, entry]));
|
||||||
|
if (resources.length !== entries.size) throw new Error("asset_resource_plan_incomplete");
|
||||||
|
mkdirSync(assetRoot, { recursive: true });
|
||||||
|
for (const resource of resources) {
|
||||||
|
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
|
||||||
|
const entry = entries.get(key);
|
||||||
|
if (!entry || JSON.stringify(entry) !== JSON.stringify({ ...resource.entry, sha256: resource.entry.sha256.toLowerCase() })) {
|
||||||
|
throw new Error("asset_resource_plan_mismatch");
|
||||||
|
}
|
||||||
|
if (!existsSync(resource.sourcePath) || !statSync(resource.sourcePath).isFile() || lstatSync(resource.sourcePath).isSymbolicLink()) {
|
||||||
|
throw new Error("asset_source_invalid");
|
||||||
|
}
|
||||||
|
if (fileSha256(resource.sourcePath) !== entry.sha256) throw new Error("asset_source_hash_invalid");
|
||||||
|
const targetPath = targetWithinRoot(assetRoot, entry.relativePath);
|
||||||
|
mkdirSync(dirname(targetPath), { recursive: true });
|
||||||
|
if (existsSync(targetPath)) {
|
||||||
|
if (fileSha256(targetPath) !== entry.sha256) throw new Error("asset_target_conflict");
|
||||||
|
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_target_not_hardlink");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
linkSync(resource.sourcePath, targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") {
|
||||||
|
throw new Error("asset_hardlink_volume_mismatch");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_hardlink_verification_failed");
|
||||||
|
}
|
||||||
|
writeRuntimeAssetManifest(join(assetRoot, "manifest.json"), normalizedManifest);
|
||||||
|
return { linked_files: resources.length, manifest: normalizedManifest, status: "ready" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneDirectoryWithPrefix(root, prefix) {
|
||||||
|
const matches = readdirSync(root, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}_`));
|
||||||
|
if (matches.length !== 1) throw new Error(`runtime_asset_source_directory_invalid:${prefix}`);
|
||||||
|
return join(root, matches[0].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneSupportedFont(root) {
|
||||||
|
const matches = readdirSync(root, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile() && fontMimeTypes.has(extname(entry.name).toLowerCase()));
|
||||||
|
if (matches.length !== 1) throw new Error(`runtime_font_source_invalid:${basename(root)}`);
|
||||||
|
return join(root, matches[0].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryFor(sourcePath, assetId, resourceVersion, relativePath, mimeType) {
|
||||||
|
return {
|
||||||
|
assetId,
|
||||||
|
mimeType,
|
||||||
|
relativePath,
|
||||||
|
resourceVersion,
|
||||||
|
rootRef: P0A_RUNTIME_ASSET_ROOT_REF,
|
||||||
|
sha256: fileSha256(sourcePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dynamicMetadata(templateRoot, descriptor, field) {
|
||||||
|
const templateDirectory = join(templateRoot, descriptor.templateId);
|
||||||
|
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
||||||
|
if (!Array.isArray(metadata?.files?.[field]) || !metadata.files[field].includes(descriptor.sourceReference)) {
|
||||||
|
throw new Error(`runtime_dynamic_reference_invalid:${descriptor.assetId}`);
|
||||||
|
}
|
||||||
|
return templateDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||||
|
const [{ compileStaticStickerCatalog }, registry] = await Promise.all([
|
||||||
|
import("../../packages/asset-compiler/dist/index.js"),
|
||||||
|
import("../../packages/template-registry/dist/index.js"),
|
||||||
|
]);
|
||||||
|
const compilerOutput = mkdtempSync(join(tmpdir(), "dada-runtime-asset-plan-"));
|
||||||
|
try {
|
||||||
|
const staticSourceRoot = join(replicationRoot, "sticker_normal");
|
||||||
|
const staticResult = compileStaticStickerCatalog({
|
||||||
|
outputDirectory: compilerOutput,
|
||||||
|
releaseVersion: registry.P0A_STATIC_STICKER_RELEASE_VERSION,
|
||||||
|
sourceRoot: staticSourceRoot,
|
||||||
|
});
|
||||||
|
const resources = staticResult.catalog.items.map((item) => {
|
||||||
|
const sourcePath = join(staticSourceRoot, ...item.relative_path.split("/"));
|
||||||
|
const entry = entryFor(
|
||||||
|
sourcePath,
|
||||||
|
item.stable_id,
|
||||||
|
registry.P0A_STATIC_STICKER_RELEASE_VERSION,
|
||||||
|
`${registry.P0A_STATIC_STICKER_RELEASE_VERSION}/${item.stable_id}.png`,
|
||||||
|
"image/png",
|
||||||
|
);
|
||||||
|
if (entry.sha256 !== item.sha256.toLowerCase()) throw new Error(`static_sticker_hash_invalid:${item.stable_id}`);
|
||||||
|
return { entry, sourcePath };
|
||||||
|
});
|
||||||
|
|
||||||
|
const fontPackagesRoot = join(
|
||||||
|
replicationRoot,
|
||||||
|
"sticker_text",
|
||||||
|
"字体",
|
||||||
|
"面板全量采集",
|
||||||
|
"font_panel_full_20260722",
|
||||||
|
"resources",
|
||||||
|
"font_packages",
|
||||||
|
);
|
||||||
|
for (const assetId of registry.P0A_REQUIRED_FONT_PANEL_IDS) {
|
||||||
|
const packageDirectory = oneDirectoryWithPrefix(fontPackagesRoot, assetId);
|
||||||
|
const sourcePath = oneSupportedFont(join(packageDirectory, "font_files"));
|
||||||
|
const extension = extname(sourcePath).toLowerCase();
|
||||||
|
resources.push({
|
||||||
|
entry: entryFor(
|
||||||
|
sourcePath,
|
||||||
|
assetId,
|
||||||
|
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||||
|
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}${extension}`,
|
||||||
|
fontMimeTypes.get(extension),
|
||||||
|
),
|
||||||
|
sourcePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
|
||||||
|
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES) {
|
||||||
|
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "fonts");
|
||||||
|
const sourcePath = oneSupportedFont(join(templateDirectory, ...descriptor.sourceReference.split("/")));
|
||||||
|
const extension = extname(sourcePath).toLowerCase();
|
||||||
|
resources.push({
|
||||||
|
entry: entryFor(
|
||||||
|
sourcePath,
|
||||||
|
descriptor.assetId,
|
||||||
|
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||||
|
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}${extension}`,
|
||||||
|
fontMimeTypes.get(extension),
|
||||||
|
),
|
||||||
|
sourcePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES) {
|
||||||
|
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "images");
|
||||||
|
const sourcePath = join(templateDirectory, ...descriptor.sourceReference.split("/"));
|
||||||
|
if (!existsSync(sourcePath) || extname(sourcePath).toLowerCase() !== ".png") {
|
||||||
|
throw new Error(`runtime_dynamic_image_invalid:${descriptor.assetId}`);
|
||||||
|
}
|
||||||
|
resources.push({
|
||||||
|
entry: entryFor(
|
||||||
|
sourcePath,
|
||||||
|
descriptor.assetId,
|
||||||
|
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||||
|
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}.png`,
|
||||||
|
"image/png",
|
||||||
|
),
|
||||||
|
sourcePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestPath = join(replicationRoot, "sticker_web_handoff", "sticker_web_catalog_manifest.json");
|
||||||
|
const manifest = createRuntimeAssetManifest({
|
||||||
|
counts: {
|
||||||
|
dynamic_fonts: registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES.length,
|
||||||
|
dynamic_images: registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES.length,
|
||||||
|
font_panel_items: registry.P0A_REQUIRED_FONT_PANEL_IDS.length,
|
||||||
|
static_stickers: staticResult.catalog.count,
|
||||||
|
},
|
||||||
|
entries: resources.map((resource) => resource.entry),
|
||||||
|
sourceManifestSha256: fileSha256(manifestPath),
|
||||||
|
});
|
||||||
|
const resourcesByKey = new Map(resources.map((resource) => [`${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`, resource]));
|
||||||
|
return {
|
||||||
|
manifest,
|
||||||
|
resources: manifest.entries.map((entry) => resourcesByKey.get(`${entry.resourceVersion}\u0000${entry.assetId}`)),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
rmSync(compilerOutput, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultReplicationRoot(environment = process.env) {
|
||||||
|
if (!environment.USERPROFILE || !isAbsolute(environment.USERPROFILE)) throw new Error("user_profile_unavailable");
|
||||||
|
return join(environment.USERPROFILE, "Desktop", "sticker_web_replication_assets");
|
||||||
|
}
|
||||||
@@ -14,7 +14,11 @@ export const DEFERRED_EXTERNAL_TASKS = Object.freeze(["TASK-WP7-03", "TASK-WP7-0
|
|||||||
export function buildFinalReleaseRecord({ appVersion, browsers, buildCommit, frozenFromCommit, recordedAt, windows }) {
|
export function buildFinalReleaseRecord({ appVersion, browsers, buildCommit, frozenFromCommit, recordedAt, windows }) {
|
||||||
const record = {
|
const record = {
|
||||||
appVersion,
|
appVersion,
|
||||||
browsers: browsers.map(({ brand, fullVersion }) => ({ brand, fullVersion })),
|
browsers: browsers.map(({ brand, fullVersion, supportedMajorVersions }) => ({
|
||||||
|
brand,
|
||||||
|
fullVersion,
|
||||||
|
...(supportedMajorVersions ? { supportedMajorVersions: [...supportedMajorVersions] } : {}),
|
||||||
|
})),
|
||||||
buildCommit: buildCommit.toLowerCase(),
|
buildCommit: buildCommit.toLowerCase(),
|
||||||
deferredExternalTasks: [...DEFERRED_EXTERNAL_TASKS],
|
deferredExternalTasks: [...DEFERRED_EXTERNAL_TASKS],
|
||||||
finalRelease: true,
|
finalRelease: true,
|
||||||
@@ -44,8 +48,25 @@ export function validateFinalReleaseRecord(record) {
|
|||||||
} else {
|
} else {
|
||||||
const brands = record.browsers.map(({ brand }) => brand).sort();
|
const brands = record.browsers.map(({ brand }) => brand).sort();
|
||||||
if (brands.join("|") !== "Google Chrome|Microsoft Edge") errors.push("browserBrands");
|
if (brands.join("|") !== "Google Chrome|Microsoft Edge") errors.push("browserBrands");
|
||||||
|
const supportedMajorCount = record.browsers.reduce(
|
||||||
|
(count, browser) => count + (Array.isArray(browser.supportedMajorVersions)
|
||||||
|
? browser.supportedMajorVersions.length
|
||||||
|
: 1),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (supportedMajorCount > 8) errors.push("supportedMajorVersions.total");
|
||||||
for (const browser of record.browsers) {
|
for (const browser of record.browsers) {
|
||||||
if (!VERSION.test(browser.fullVersion ?? "")) errors.push(`${browser.brand}.fullVersion`);
|
if (!VERSION.test(browser.fullVersion ?? "")) errors.push(`${browser.brand}.fullVersion`);
|
||||||
|
if (browser.supportedMajorVersions !== undefined) {
|
||||||
|
const values = browser.supportedMajorVersions;
|
||||||
|
const baselineMajor = Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10);
|
||||||
|
if (!Array.isArray(values) || values.length === 0 || values.length > 8
|
||||||
|
|| values.some((value) => !Number.isSafeInteger(value) || value < 1)
|
||||||
|
|| new Set(values).size !== values.length
|
||||||
|
|| !values.includes(baselineMajor)) {
|
||||||
|
errors.push(`${browser.brand}.supportedMajorVersions`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if ("path" in browser || "executablePath" in browser || "executableSha256" in browser) errors.push(`${browser.brand}.privateMetadata`);
|
if ("path" in browser || "executablePath" in browser || "executableSha256" in browser) errors.push(`${browser.brand}.privateMetadata`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ internal static class Program
|
|||||||
};
|
};
|
||||||
var state = await runtime.StartAsync();
|
var state = await runtime.StartAsync();
|
||||||
if (!form.IsDisposed) form.SetState(state);
|
if (!form.IsDisposed) form.SetState(state);
|
||||||
if (state == SupervisorState.Ready) SupervisorForm.OpenProductInSupportedBrowser();
|
if (state == SupervisorState.Ready && !SupervisorForm.OpenProductInSupportedBrowser())
|
||||||
|
{
|
||||||
|
form.SetBrowserLaunchFailure();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
form.Shown += async (_, _) => await StartRuntimeAsync();
|
form.Shown += async (_, _) => await StartRuntimeAsync();
|
||||||
form.RestartRequested += async () => await StartRuntimeAsync();
|
form.RestartRequested += async () => await StartRuntimeAsync();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ internal sealed class SupervisorForm : Form
|
|||||||
Font = new Font("Segoe UI", 9F);
|
Font = new Font("Segoe UI", 9F);
|
||||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
MaximizeBox = false;
|
MaximizeBox = false;
|
||||||
|
ShowInTaskbar = true;
|
||||||
StartPosition = FormStartPosition.CenterScreen;
|
StartPosition = FormStartPosition.CenterScreen;
|
||||||
Text = "Dada";
|
Text = "Dada";
|
||||||
|
|
||||||
@@ -90,10 +91,6 @@ internal sealed class SupervisorForm : Form
|
|||||||
trayIcon.DoubleClick += (_, _) => RestoreWindow();
|
trayIcon.DoubleClick += (_, _) => RestoreWindow();
|
||||||
|
|
||||||
FormClosing += (_, _) => trayIcon.Visible = false;
|
FormClosing += (_, _) => trayIcon.Visible = false;
|
||||||
Resize += (_, _) =>
|
|
||||||
{
|
|
||||||
if (WindowState == FormWindowState.Minimized) Hide();
|
|
||||||
};
|
|
||||||
SetState(initialState);
|
SetState(initialState);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +136,14 @@ internal sealed class SupervisorForm : Form
|
|||||||
Activate();
|
Activate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void SetBrowserLaunchFailure()
|
||||||
|
{
|
||||||
|
if (state == SupervisorState.Ready)
|
||||||
|
{
|
||||||
|
statusDetail.Text = "本机服务运行正常,但未能自动打开浏览器;请点击“打开 Dada”或选择浏览器。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
if (disposing) trayIcon.Dispose();
|
if (disposing) trayIcon.Dispose();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
|
|
||||||
@@ -13,10 +14,20 @@ internal static class SupportedBrowserLauncher
|
|||||||
{
|
{
|
||||||
var executable = FindExecutable(executableName);
|
var executable = FindExecutable(executableName);
|
||||||
if (executable is null) return false;
|
if (executable is null) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false };
|
var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false };
|
||||||
startInfo.ArgumentList.Add(uri.AbsoluteUri);
|
startInfo.ArgumentList.Add(uri.AbsoluteUri);
|
||||||
Process.Start(startInfo);
|
return Process.Start(startInfo) is not null;
|
||||||
return true;
|
}
|
||||||
|
catch (Win32Exception)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? FindExecutable(string executableName)
|
private static string? FindExecutable(string executableName)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { loadConfiguredRuntimeAssets } from "../../apps/api/src/runtime-assets.js";
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
|
if (resolve(directory).startsWith(resolve(tmpdir()))) rmSync(directory, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const base = mkdtempSync(join(tmpdir(), "dada-postv1-assets-"));
|
||||||
|
temporaryDirectories.push(base);
|
||||||
|
const assetRoot = join(base, "assets");
|
||||||
|
const dataRoot = join(base, "data");
|
||||||
|
const configFile = join(base, "instance.json");
|
||||||
|
const trustedManifestPath = join(base, "trusted-manifest.json");
|
||||||
|
const bytes = Buffer.from("synthetic sticker bytes");
|
||||||
|
const entry = {
|
||||||
|
assetId: "STK001",
|
||||||
|
mimeType: "image/png",
|
||||||
|
relativePath: "p0a-static-v1/STK001.png",
|
||||||
|
resourceVersion: "p0a-static-v1",
|
||||||
|
rootRef: "p0a_runtime_assets",
|
||||||
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
};
|
||||||
|
const manifest = {
|
||||||
|
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||||
|
entries: [entry],
|
||||||
|
root_ref: "p0a_runtime_assets",
|
||||||
|
schema_version: "DadaRuntimeAssets/v1",
|
||||||
|
source: "external_read_only",
|
||||||
|
};
|
||||||
|
mkdirSync(join(assetRoot, "p0a-static-v1"), { recursive: true });
|
||||||
|
writeFileSync(join(assetRoot, entry.relativePath), bytes);
|
||||||
|
writeFileSync(join(assetRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
writeFileSync(trustedManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
writeFileSync(configFile, JSON.stringify({ asset_root: assetRoot }));
|
||||||
|
return { assetRoot, configFile, dataRoot, trustedManifestPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POSTV1-06 portable runtime assets", () => {
|
||||||
|
it("activates a validated external asset root without exposing its path", () => {
|
||||||
|
const input = fixture();
|
||||||
|
const loaded = loadConfiguredRuntimeAssets(input);
|
||||||
|
|
||||||
|
expect(loaded.state).toMatchObject({ configured: true, pause_reason: null, status: "active" });
|
||||||
|
expect(loaded.publicAssets?.read("p0a-static-v1", "STK001")?.bytes.toString()).toBe("synthetic sticker bytes");
|
||||||
|
expect(JSON.stringify(loaded.state)).not.toContain(input.assetRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a changed external manifest and leaves unrelated API features available", () => {
|
||||||
|
const input = fixture();
|
||||||
|
writeFileSync(join(input.assetRoot, "manifest.json"), "{}\n");
|
||||||
|
const loaded = loadConfiguredRuntimeAssets(input);
|
||||||
|
|
||||||
|
expect(loaded.publicAssets).toBeUndefined();
|
||||||
|
expect(loaded.state).toMatchObject({ configured: true, pause_reason: "asset_manifest_invalid", status: "unavailable" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,14 @@ const supportedEdge = browserSupportFixture({
|
|||||||
brand: "Microsoft Edge",
|
brand: "Microsoft Edge",
|
||||||
fullVersion: "150.0.4078.99",
|
fullVersion: "150.0.4078.99",
|
||||||
});
|
});
|
||||||
|
const supportedChrome150 = browserSupportFixture({
|
||||||
|
brand: "Google Chrome",
|
||||||
|
fullVersion: "150.0.7871.187",
|
||||||
|
});
|
||||||
|
const supportedChrome151 = browserSupportFixture({
|
||||||
|
brand: "Google Chrome",
|
||||||
|
fullVersion: "151.0.0.0",
|
||||||
|
});
|
||||||
const rejectedIdentityCases = [
|
const rejectedIdentityCases = [
|
||||||
{
|
{
|
||||||
expectedReason: "platform_unsupported",
|
expectedReason: "platform_unsupported",
|
||||||
@@ -145,6 +153,7 @@ describe("TDD-WP0-BRW-001 supported browser contract", () => {
|
|||||||
status: "supported",
|
status: "supported",
|
||||||
supported_browsers: [
|
supported_browsers: [
|
||||||
{ brand: "Google Chrome", major: 150 },
|
{ brand: "Google Chrome", major: 150 },
|
||||||
|
{ brand: "Google Chrome", major: 151 },
|
||||||
{ brand: "Microsoft Edge", major: 150 },
|
{ brand: "Microsoft Edge", major: 150 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -188,6 +197,26 @@ describe("TDD-WP0-BRW-001 supported browser contract", () => {
|
|||||||
expect(staleCookie.statusCode).toBe(426);
|
expect(staleCookie.statusCode).toBe(426);
|
||||||
await restarted.close();
|
await restarted.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ expectedMajor: 150, fixture: supportedChrome150 },
|
||||||
|
{ expectedMajor: 151, fixture: supportedChrome151 },
|
||||||
|
])("accepts explicitly declared Chrome $expectedMajor", async ({ expectedMajor, fixture }) => {
|
||||||
|
const app = await createApp({ browserSupportRelease: testBrowserSupportRelease } as never);
|
||||||
|
const checked = await app.inject({
|
||||||
|
headers: fixture.headers,
|
||||||
|
method: "POST",
|
||||||
|
payload: fixture.body,
|
||||||
|
url: "/api/v1/support/check",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checked.statusCode).toBe(200);
|
||||||
|
expect(checked.json()).toMatchObject({
|
||||||
|
browser: { brand: "Google Chrome", major: expectedMajor },
|
||||||
|
status: "supported",
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TDD-WP0-BRW-002 hard block", () => {
|
describe("TDD-WP0-BRW-002 hard block", () => {
|
||||||
@@ -208,6 +237,7 @@ describe("TDD-WP0-BRW-002 hard block", () => {
|
|||||||
reason: expectedReason,
|
reason: expectedReason,
|
||||||
supported_browsers: [
|
supported_browsers: [
|
||||||
{ brand: "Google Chrome", major: 150 },
|
{ brand: "Google Chrome", major: 150 },
|
||||||
|
{ brand: "Google Chrome", major: 151 },
|
||||||
{ brand: "Microsoft Edge", major: 150 },
|
{ brand: "Microsoft Edge", major: 150 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("TDD-WP0-DATA-001-root-validation resource boundary", () => {
|
|||||||
const assetRoot = join(base, "read-only-assets");
|
const assetRoot = join(base, "read-only-assets");
|
||||||
const relativePath = "images/source.png";
|
const relativePath = "images/source.png";
|
||||||
const bytes = Buffer.from("synthetic png fixture");
|
const bytes = Buffer.from("synthetic png fixture");
|
||||||
const assetId = randomUUID();
|
const assetId = "STK001";
|
||||||
mkdirSync(join(assetRoot, "images"), { recursive: true });
|
mkdirSync(join(assetRoot, "images"), { recursive: true });
|
||||||
writeFileSync(join(assetRoot, relativePath), bytes);
|
writeFileSync(join(assetRoot, relativePath), bytes);
|
||||||
const manifest = JSON.stringify({ assets: [{ asset_id: assetId, relative_path: relativePath }] });
|
const manifest = JSON.stringify({ assets: [{ asset_id: assetId, relative_path: relativePath }] });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const testBrowserSupportRelease = {
|
export const testBrowserSupportRelease = {
|
||||||
appVersion: "1.2.3-test",
|
appVersion: "1.2.3-test",
|
||||||
browsers: [
|
browsers: [
|
||||||
{ brand: "Google Chrome", fullVersion: "150.0.7339.1" },
|
{ brand: "Google Chrome", fullVersion: "150.0.7339.1", supportedMajorVersions: [150, 151] },
|
||||||
{ brand: "Microsoft Edge", fullVersion: "150.0.4078.99" },
|
{ brand: "Microsoft Edge", fullVersion: "150.0.4078.99" },
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
defaultLocalDataRoot,
|
defaultLocalDataRoot,
|
||||||
initializeLocalDataRoot,
|
initializeLocalDataRoot,
|
||||||
inspectInitializedLocalDataRoot,
|
inspectInitializedLocalDataRoot,
|
||||||
|
readConfiguredAssetRoot,
|
||||||
resolvePathWithinRoot,
|
resolvePathWithinRoot,
|
||||||
validateLocalDataRoot,
|
validateLocalDataRoot,
|
||||||
validateReadOnlyAssetRoot,
|
validateReadOnlyAssetRoot,
|
||||||
@@ -66,6 +67,17 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("TDD-WP0-DATA-001-root-validation", () => {
|
describe("TDD-WP0-DATA-001-root-validation", () => {
|
||||||
|
it("reads only an absolute configured read-only asset root", () => {
|
||||||
|
const base = temporaryDirectory();
|
||||||
|
const configFile = join(base, "instance.json");
|
||||||
|
const assetRoot = join(base, "runtime-assets");
|
||||||
|
writeFileSync(configFile, JSON.stringify({ asset_root: assetRoot }));
|
||||||
|
|
||||||
|
expect(readConfiguredAssetRoot(configFile)).toBe(resolve(assetRoot));
|
||||||
|
writeFileSync(configFile, JSON.stringify({ asset_root: "relative-assets" }));
|
||||||
|
expect(() => readConfiguredAssetRoot(configFile)).toThrow("asset_root_configuration_invalid");
|
||||||
|
});
|
||||||
|
|
||||||
it("derives default data and configuration paths from LOCALAPPDATA without a hardcoded user", () => {
|
it("derives default data and configuration paths from LOCALAPPDATA without a hardcoded user", () => {
|
||||||
const localAppData = join(temporaryDirectory(), "LocalAppData");
|
const localAppData = join(temporaryDirectory(), "LocalAppData");
|
||||||
expect(defaultLocalDataRoot({ LOCALAPPDATA: localAppData })).toBe(join(localAppData, "Dada", "P0A", "data"));
|
expect(defaultLocalDataRoot({ LOCALAPPDATA: localAppData })).toBe(join(localAppData, "Dada", "P0A", "data"));
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRuntimeAssetManifest,
|
||||||
|
deployRuntimeAssetPlan,
|
||||||
|
readRuntimeAssetManifest,
|
||||||
|
serializeRuntimeAssetManifest,
|
||||||
|
} from "../../scripts/lib/runtime-assets.mjs";
|
||||||
|
|
||||||
|
test("committed P0-A runtime manifest covers the frozen first-version binary assets", () => {
|
||||||
|
const manifest = readRuntimeAssetManifest("config/runtime-assets-manifest.json");
|
||||||
|
assert.deepEqual(manifest.counts, {
|
||||||
|
dynamic_fonts: 7,
|
||||||
|
dynamic_images: 8,
|
||||||
|
font_panel_items: 11,
|
||||||
|
static_stickers: 1407,
|
||||||
|
});
|
||||||
|
assert.equal(manifest.entries.length, 1433);
|
||||||
|
assert.doesNotMatch(serializeRuntimeAssetManifest(manifest), /[A-Za-z]:[\\/]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runtime asset deployment creates verified hardlinks and a path-free manifest", async (t) => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-"));
|
||||||
|
t.after(() => rm(root, { force: true, recursive: true }));
|
||||||
|
const sourceRoot = join(root, "source");
|
||||||
|
const assetRoot = join(root, "assets");
|
||||||
|
const sourcePath = join(sourceRoot, "sticker.png");
|
||||||
|
const bytes = Buffer.from("runtime asset fixture");
|
||||||
|
await mkdir(sourceRoot);
|
||||||
|
await writeFile(sourcePath, bytes);
|
||||||
|
const entry = {
|
||||||
|
assetId: "STK001",
|
||||||
|
mimeType: "image/png",
|
||||||
|
relativePath: "p0a-static-v1/STK001.png",
|
||||||
|
resourceVersion: "p0a-static-v1",
|
||||||
|
rootRef: "p0a_runtime_assets",
|
||||||
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
};
|
||||||
|
const manifest = createRuntimeAssetManifest({
|
||||||
|
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
await deployRuntimeAssetPlan({ assetRoot, manifest, resources: [{ entry, sourcePath }] });
|
||||||
|
|
||||||
|
const targetPath = join(assetRoot, entry.relativePath);
|
||||||
|
const [sourceStat, targetStat] = await Promise.all([stat(sourcePath), stat(targetPath)]);
|
||||||
|
assert.equal(sourceStat.ino, targetStat.ino);
|
||||||
|
assert.deepEqual(await readFile(targetPath), bytes);
|
||||||
|
const writtenManifest = await readFile(join(assetRoot, "manifest.json"), "utf8");
|
||||||
|
assert.deepEqual(JSON.parse(writtenManifest), manifest);
|
||||||
|
assert.doesNotMatch(writtenManifest, /[A-Za-z]:[\\/]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runtime asset deployment refuses a mismatched existing target", async (t) => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-conflict-"));
|
||||||
|
t.after(() => rm(root, { force: true, recursive: true }));
|
||||||
|
const sourcePath = join(root, "source.png");
|
||||||
|
const assetRoot = join(root, "assets");
|
||||||
|
const targetPath = join(assetRoot, "p0a-static-v1", "STK001.png");
|
||||||
|
await mkdir(join(assetRoot, "p0a-static-v1"), { recursive: true });
|
||||||
|
await writeFile(sourcePath, "expected");
|
||||||
|
await writeFile(targetPath, "unexpected");
|
||||||
|
const entry = {
|
||||||
|
assetId: "STK001",
|
||||||
|
mimeType: "image/png",
|
||||||
|
relativePath: "p0a-static-v1/STK001.png",
|
||||||
|
resourceVersion: "p0a-static-v1",
|
||||||
|
rootRef: "p0a_runtime_assets",
|
||||||
|
sha256: createHash("sha256").update("expected").digest("hex"),
|
||||||
|
};
|
||||||
|
const manifest = createRuntimeAssetManifest({
|
||||||
|
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => deployRuntimeAssetPlan({ assetRoot, manifest, resources: [{ entry, sourcePath }] }),
|
||||||
|
/asset_target_conflict/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -112,6 +112,14 @@ async function verifyWorkerStartup(configPath) {
|
|||||||
test("portable package serves the product and keeps SQLite data across API restart", async () => {
|
test("portable package serves the product and keeps SQLite data across API restart", async () => {
|
||||||
assert.ok(existsSync(join(packageRoot, "Dada.exe")));
|
assert.ok(existsSync(join(packageRoot, "Dada.exe")));
|
||||||
assert.ok(existsSync(join(packageRoot, "web", "index.html")));
|
assert.ok(existsSync(join(packageRoot, "web", "index.html")));
|
||||||
|
const runtimeAssetManifest = JSON.parse(await readFile(join(packageRoot, "asset-metadata", "manifest.json"), "utf8"));
|
||||||
|
assert.deepEqual(runtimeAssetManifest.counts, {
|
||||||
|
dynamic_fonts: 7,
|
||||||
|
dynamic_images: 8,
|
||||||
|
font_panel_items: 11,
|
||||||
|
static_stickers: 1407,
|
||||||
|
});
|
||||||
|
assert.equal(runtimeAssetManifest.entries.length, 1433);
|
||||||
const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8");
|
const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8");
|
||||||
const packagedOneApiAdapter = await readFile(join(packageRoot, "server", "worker", "dist", "oneapi-generation-adapter.js"), "utf8");
|
const packagedOneApiAdapter = await readFile(join(packageRoot, "server", "worker", "dist", "oneapi-generation-adapter.js"), "utf8");
|
||||||
assert.match(packagedWorker, /GenerationProcessor/);
|
assert.match(packagedWorker, /GenerationProcessor/);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ function release() {
|
|||||||
return buildFinalReleaseRecord({
|
return buildFinalReleaseRecord({
|
||||||
appVersion: "0.0.0",
|
appVersion: "0.0.0",
|
||||||
browsers: [
|
browsers: [
|
||||||
{ brand: "Google Chrome", fullVersion: "150.0.7871.187" },
|
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 151] },
|
||||||
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
|
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
|
||||||
],
|
],
|
||||||
buildCommit: "a".repeat(40),
|
buildCommit: "a".repeat(40),
|
||||||
@@ -26,6 +26,32 @@ test("TDD-WP7-REL-001 creates a browser-gate compatible first-version record", (
|
|||||||
assert.equal(record.fixedPort, 43121);
|
assert.equal(record.fixedPort, 43121);
|
||||||
assert.deepEqual(record.deferredExternalTasks, ["TASK-WP7-03", "TASK-WP7-04"]);
|
assert.deepEqual(record.deferredExternalTasks, ["TASK-WP7-03", "TASK-WP7-04"]);
|
||||||
assert.deepEqual(record.browsers.map(({ brand }) => brand).sort(), ["Google Chrome", "Microsoft Edge"]);
|
assert.deepEqual(record.browsers.map(({ brand }) => brand).sort(), ["Google Chrome", "Microsoft Edge"]);
|
||||||
|
assert.deepEqual(record.browsers[0].supportedMajorVersions, [150, 151]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TDD-WP7-REL-001 rejects unsafe or duplicate browser major lists", () => {
|
||||||
|
assert.throws(() => buildFinalReleaseRecord({
|
||||||
|
appVersion: "0.0.0",
|
||||||
|
browsers: [
|
||||||
|
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 150] },
|
||||||
|
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
|
||||||
|
],
|
||||||
|
buildCommit: "a".repeat(40),
|
||||||
|
frozenFromCommit: "b".repeat(40),
|
||||||
|
recordedAt: "2026-08-04T06:00:00.000Z",
|
||||||
|
windows: { arch: "x64", build: "26200.8875", displayVersion: "25H2" },
|
||||||
|
}), /Google Chrome\.supportedMajorVersions/);
|
||||||
|
assert.throws(() => buildFinalReleaseRecord({
|
||||||
|
appVersion: "0.0.0",
|
||||||
|
browsers: [
|
||||||
|
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 151, 152, 153, 154, 155, 156, 157] },
|
||||||
|
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
|
||||||
|
],
|
||||||
|
buildCommit: "a".repeat(40),
|
||||||
|
frozenFromCommit: "b".repeat(40),
|
||||||
|
recordedAt: "2026-08-04T06:00:00.000Z",
|
||||||
|
windows: { arch: "x64", build: "26200.8875", displayVersion: "25H2" },
|
||||||
|
}), /supportedMajorVersions\.total/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("TDD-WP7-SEC-001 rejects credential shapes and absolute user paths", () => {
|
test("TDD-WP7-SEC-001 rejects credential shapes and absolute user paths", () => {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { GenerationPollingLoop } from "../../apps/worker/src/generation-polling-loop.js";
|
||||||
|
|
||||||
|
describe("POSTV1-05 generation polling loop", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start another processor call while the current call is unresolved", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let finishCurrentCall: (() => void) | undefined;
|
||||||
|
const processNext = vi.fn(() => new Promise<void>((resolve) => {
|
||||||
|
finishCurrentCall = resolve;
|
||||||
|
}));
|
||||||
|
const loop = new GenerationPollingLoop({ processNext }, 250);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
expect(processNext).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
finishCurrentCall?.();
|
||||||
|
await Promise.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
expect(processNext).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
loop.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,14 @@ describe("POSTV1-02 OneAPI runtime adapter", () => {
|
|||||||
const headers = new Headers(init?.headers);
|
const headers = new Headers(init?.headers);
|
||||||
expect(headers.get("authorization")).toBe("Bearer synthetic-runtime-token");
|
expect(headers.get("authorization")).toBe("Bearer synthetic-runtime-token");
|
||||||
expect(init?.redirect).toBe("error");
|
expect(init?.redirect).toBe("error");
|
||||||
|
const payload = JSON.parse(String(init?.body)) as { messages: Array<{ content: unknown; role: string }> };
|
||||||
|
expect(payload.messages).toEqual([
|
||||||
|
{
|
||||||
|
content: "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.",
|
||||||
|
role: "system",
|
||||||
|
},
|
||||||
|
{ content: "一张用于本机验收的抽象色彩图", role: "user" },
|
||||||
|
]);
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
choices: [{ message: { content: `})` } }],
|
choices: [{ message: { content: `})` } }],
|
||||||
}), { headers: { "content-type": "application/json" }, status: 200 });
|
}), { headers: { "content-type": "application/json" }, status: 200 });
|
||||||
|
|||||||
Reference in New Issue
Block a user