test(obsidian): cover Adaptive PostgREST remotes

This commit is contained in:
vorotamoroz
2026-08-02 08:02:41 +00:00
parent 1fb32cb625
commit ce6dc67975
6 changed files with 307 additions and 2 deletions
@@ -0,0 +1,279 @@
/**
* Exercises the Adaptive-only PostgREST Journal provider through visible,
* safety-gated onboarding. The shared Adaptive Journal runner owns restart
* persistence, device-generated Setup URI transfer, Fast Fetch, and a
* two-device text and binary return journey.
*/
import { parsePostgRESTConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
import { runAdaptiveJournalObsidianRoundTrip, runNpmScript } from "../runner/adaptiveJournal.ts";
import { assertEqual } from "../runner/liveSyncWorkflow.ts";
import { captureGuideDialogue, modalByTitle, selectRadioOption, type SetupState } from "../runner/setupUri.ts";
import { withObsidianPage } from "../runner/ui.ts";
type PostgRESTConfig = {
apiKey: string;
endpoint: string;
schema: string;
vaultCredential: string;
vaultId: string;
};
const supportedArguments = new Set(["--keep-postgrest", "--manage-postgrest"]);
const unsupportedArguments = process.argv.slice(2).filter((argument) => !supportedArguments.has(argument));
if (unsupportedArguments.length > 0) {
throw new Error(`Unsupported Adaptive PostgREST argument: ${unsupportedArguments.join(", ")}`);
}
const managePostgREST = process.argv.includes("--manage-postgrest");
const keepPostgRESTFixture = process.argv.includes("--keep-postgrest");
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
const remoteTimeoutMs = Number(process.env.E2E_OBSIDIAN_POSTGREST_TIMEOUT_MS ?? 30000);
function environmentValue(primary: string, legacy: string, fallback: string): string {
return process.env[primary] ?? process.env[legacy] ?? fallback;
}
function loadPostgRESTConfig(): PostgRESTConfig {
const endpoint = environmentValue("POSTGREST_ENDPOINT", "postgrestEndpoint", "http://127.0.0.1:3001").replace(
/\/+$/u,
""
);
const endpointUrl = new URL(endpoint);
if (
(endpointUrl.protocol !== "http:" && endpointUrl.protocol !== "https:") ||
endpointUrl.username !== "" ||
endpointUrl.password !== "" ||
endpointUrl.search !== "" ||
endpointUrl.hash !== ""
) {
throw new Error("Adaptive PostgREST requires a complete HTTP or HTTPS endpoint without embedded credentials.");
}
const config = {
apiKey: environmentValue("POSTGREST_API_KEY", "postgrestApiKey", ""),
endpoint,
schema: environmentValue("POSTGREST_SCHEMA", "postgrestSchema", "livesync_api"),
vaultCredential: environmentValue(
"POSTGREST_VAULT_CREDENTIAL",
"postgrestVaultCredential",
"adaptive-cli-vault-credential-0000000000001"
),
vaultId: environmentValue("POSTGREST_VAULT_ID", "postgrestVaultId", "adaptive-cli-vault-01"),
};
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(config.schema)) {
throw new Error("Adaptive PostgREST requires a valid exposed schema identifier.");
}
if (!/^[A-Za-z0-9_-]{16,128}$/u.test(config.vaultId) || config.vaultCredential.length === 0) {
throw new Error("Adaptive PostgREST requires a provisioned Vault ID and Vault credential.");
}
return config;
}
function postgRESTHeaders(config: PostgRESTConfig): Headers {
const headers = new Headers({
Accept: "application/json",
"Accept-Profile": config.schema,
"Content-Profile": config.schema,
"X-LiveSync-Vault-Credential": config.vaultCredential,
"X-LiveSync-Vault-ID": config.vaultId,
});
if (config.apiKey) headers.set("apikey", config.apiKey);
return headers;
}
async function readEstimatedSize(config: PostgRESTConfig): Promise<number> {
const response = await fetch(`${config.endpoint}/rpc/livesync_adaptive_status`, {
headers: postgRESTHeaders(config),
method: "GET",
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
throw new Error(`Adaptive PostgREST status failed with HTTP ${response.status}.`);
}
const value = (await response.json()) as unknown;
const body = Array.isArray(value) && value.length === 1 ? value[0] : value;
const estimatedSize = Number(
body && typeof body === "object" && !Array.isArray(body)
? (body as { estimated_size?: unknown }).estimated_size
: Number.NaN
);
if (!Number.isFinite(estimatedSize) || estimatedSize < 0) {
throw new Error("Adaptive PostgREST status returned an invalid estimated size.");
}
return estimatedSize;
}
async function assertPostgRESTReachable(config: PostgRESTConfig): Promise<void> {
await readEstimatedSize(config);
}
async function enterManualAdaptivePostgRESTSettings(
port: number,
config: PostgRESTConfig,
vaultPassphrase: string
): Promise<string[]> {
const screenshots: string[] = [];
await withObsidianPage(port, async (page) => {
const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync");
await intro.waitFor({ state: "visible", timeout: uiTimeoutMs });
await selectRadioOption(intro, "I am setting this up for the first time");
await intro
.getByRole("button", { name: "Yes, I want to set up a new synchronisation" })
.click({ timeout: uiTimeoutMs });
const method = modalByTitle(page, "Connection Method");
await method.waitFor({ state: "visible", timeout: uiTimeoutMs });
await selectRadioOption(method, "Configure a remote manually");
await method.getByRole("button", { name: "Proceed with manual configuration" }).click({ timeout: uiTimeoutMs });
const encryption = modalByTitle(page, "End-to-End Encryption");
await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs });
await encryption
.locator("label.row")
.filter({ hasText: "End-to-End Encryption" })
.locator('input[type="checkbox"]')
.first()
.check({ timeout: uiTimeoutMs });
await encryption.locator('input[name="e2ee-passphrase"]').fill(vaultPassphrase);
});
screenshots.push(
await captureGuideDialogue(port, "guide-adaptive-postgrest-encryption.png", "End-to-End Encryption")
);
await withObsidianPage(port, async (page) => {
await modalByTitle(page, "End-to-End Encryption")
.getByRole("button", { name: "Proceed", exact: true })
.click({ timeout: uiTimeoutMs });
const remoteSelection = modalByTitle(page, "Choose a synchronisation remote");
await remoteSelection.waitFor({ state: "visible", timeout: uiTimeoutMs });
await selectRadioOption(remoteSelection, "PostgREST Journal");
await remoteSelection
.getByRole("button", { name: "Continue to PostgREST setup", exact: true })
.click({ timeout: uiTimeoutMs });
const postgRESTModal = modalByTitle(page, "PostgREST Journal Configuration");
await postgRESTModal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await postgRESTModal.locator('input[name="postgrest-endpoint"]').fill(config.endpoint);
await postgRESTModal.locator('input[name="postgrest-vault-id"]').fill(config.vaultId);
await postgRESTModal.locator('input[name="postgrest-vault-credential"]').fill(config.vaultCredential);
await postgRESTModal.locator('input[name="postgrest-schema"]').fill(config.schema);
if (config.apiKey) await postgRESTModal.locator('input[name="postgrest-api-key"]').fill(config.apiKey);
await postgRESTModal.locator('input[name="postgrest-use-internal-api"]').check({ timeout: uiTimeoutMs });
if (
(await postgRESTModal.locator('select[name="postgrest-journal-format"]').count()) !== 0 ||
(await postgRESTModal.locator('select[name="postgrest-pack-read-policy"]').count()) !== 0
) {
throw new Error("Adaptive-only PostgREST onboarding exposed an Opaque or Pack retrieval choice.");
}
if (
(await postgRESTModal
.getByRole("button", { name: "Continue with verified settings", exact: true })
.count()) !== 0
) {
throw new Error("Adaptive PostgREST could continue before its server safety check completed.");
}
await postgRESTModal
.getByRole("button", { name: "Check PostgREST server", exact: true })
.click({ timeout: uiTimeoutMs });
await postgRESTModal
.getByText("The required PostgREST RPC operations and binary semantics were verified.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await postgRESTModal
.getByRole("button", { name: "Continue with verified settings", exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if (
(await postgRESTModal.getByRole("button", { name: "Save without connecting", exact: true }).count()) !== 0
) {
throw new Error("Adaptive PostgREST onboarding exposed the unverified Settings-only save action.");
}
});
screenshots.push(
await captureGuideDialogue(port, "guide-adaptive-postgrest-safety-check.png", "PostgREST Journal Configuration")
);
await withObsidianPage(port, async (page) => {
const postgRESTModal = modalByTitle(page, "PostgREST Journal Configuration");
await postgRESTModal
.getByRole("button", { name: "Continue with verified settings", exact: true })
.click({ timeout: uiTimeoutMs });
await modalByTitle(page, "Setup Complete: Preparing to Initialise Server").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
return screenshots;
}
function assertAdaptivePostgRESTSettings(state: SetupState, config: PostgRESTConfig, label: string): void {
assertEqual(state.remoteType, "POSTGREST", `${label} did not retain PostgREST as its active remote.`);
assertEqual(state.journalFormat, "adaptive-v1", `${label} did not retain the Adaptive Journal format.`);
assertEqual(state.packReadPolicy, "whole-pack", `${label} did not retain complete Pack retrieval.`);
assertEqual(state.remoteConfigurationCount, 1, `${label} did not retain exactly one remote profile.`);
const connection = parsePostgRESTConnectionURI(state.postgrestActiveConnectionURI);
assertEqual(connection.endpoint, config.endpoint, `${label} retained a different PostgREST endpoint.`);
assertEqual(connection.schema, config.schema, `${label} retained a different PostgREST schema.`);
if (connection.vaultId !== config.vaultId || connection.vaultCredential !== config.vaultCredential) {
throw new Error(`${label} did not retain the provisioned PostgREST Vault ID and Vault credential.`);
}
if (connection.apiKey !== config.apiKey) {
throw new Error(`${label} did not retain the configured PostgREST client API key.`);
}
assertEqual(
connection.useCustomRequestHandler,
true,
`${label} did not retain the Obsidian internal request API selection.`
);
}
async function main(): Promise<void> {
const config = loadPostgRESTConfig();
let shouldStopPostgREST = false;
let observedEstimatedSize = 0;
try {
if (managePostgREST) {
await runNpmScript("test:docker-postgrest:start");
shouldStopPostgREST = !keepPostgRESTFixture;
}
await assertPostgRESTReachable(config);
await runAdaptiveJournalObsidianRoundTrip({
label: "Adaptive PostgREST",
slug: "adaptive-postgrest",
targetDescription: `Temporary PostgREST target: ${config.endpoint}`,
enterManualSettings: async (port, vaultPassphrase) =>
await enterManualAdaptivePostgRESTSettings(port, config, vaultPassphrase),
assertSettings: (state, label) => assertAdaptivePostgRESTSettings(state, config, label),
assertPublished: async (minimumWriters, minimumCommits) => {
const deadline = Date.now() + remoteTimeoutMs;
let lastSize = 0;
while (Date.now() < deadline) {
lastSize = await readEstimatedSize(config);
if (lastSize > observedEstimatedSize && lastSize > 0) {
observedEstimatedSize = lastSize;
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(
`Timed out waiting for Adaptive PostgREST publication stage ${minimumWriters}/${minimumCommits}; last estimated size was ${lastSize}.`
);
},
});
} finally {
if (shouldStopPostgREST) {
await runNpmScript("test:docker-postgrest:stop").catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+19
View File
@@ -31,6 +31,7 @@ const testSteps: Step[] = [
args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"],
},
{ name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] },
{ name: "Adaptive PostgREST workflow", args: ["run", "test:e2e:obsidian:adaptive-postgrest"] },
{
name: "Object Storage Setup URI workflow",
args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"],
@@ -47,10 +48,12 @@ const testSteps: Step[] = [
const manageCouchDb = process.argv.includes("--manage-couchdb") || process.argv.includes("--manage-services");
const manageMinio = process.argv.includes("--manage-minio") || process.argv.includes("--manage-services");
const manageP2P = process.argv.includes("--manage-p2p") || process.argv.includes("--manage-services");
const managePostgREST = process.argv.includes("--manage-postgrest") || process.argv.includes("--manage-services");
const keepServices = process.argv.includes("--keep-services");
const keepCouchDb = keepServices || process.argv.includes("--keep-couchdb");
const keepMinio = keepServices || process.argv.includes("--keep-minio");
const keepP2P = keepServices || process.argv.includes("--keep-p2p");
const keepPostgREST = keepServices || process.argv.includes("--keep-postgrest");
function npmBinary(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
@@ -105,10 +108,18 @@ async function stopManagedP2P(): Promise<void> {
});
}
async function stopManagedPostgREST(): Promise<void> {
await runStep({
name: "stop PostgREST fixture",
args: ["run", "test:docker-postgrest:stop"],
optional: true,
});
}
async function main(): Promise<void> {
let shouldStopCouchDb = false;
let shouldStopMinio = false;
let shouldStopP2P = false;
let shouldStopPostgREST = false;
try {
if (manageCouchDb) {
await stopManagedCouchDb();
@@ -125,11 +136,19 @@ async function main(): Promise<void> {
await runStep({ name: "start P2P relay fixture", args: ["run", "test:docker-p2p:start"] });
shouldStopP2P = !keepP2P;
}
if (managePostgREST) {
await stopManagedPostgREST();
await runStep({ name: "start PostgREST fixture", args: ["run", "test:docker-postgrest:start"] });
shouldStopPostgREST = !keepPostgREST;
}
for (const step of testSteps) {
await runStep(step);
}
} finally {
if (shouldStopPostgREST) {
await stopManagedPostgREST();
}
if (shouldStopP2P) {
await stopManagedP2P();
}
+2 -1
View File
@@ -19,6 +19,7 @@ const focusedScenarios = new Set([
"couchdb-manual-setup-workflow",
"cli-to-obsidian-sync",
"minio-upload",
"adaptive-postgrest",
"object-storage-setup-uri-workflow",
"p2p-setup-uri-workflow",
"startup-scan",
@@ -39,7 +40,7 @@ real-Obsidian scenario. Supported scenarios:
${[...focusedScenarios].map((scenario) => ` ${scenario}`).join("\n")}
This wrapper does not start CouchDB, Object Storage, or the P2P signalling
This wrapper does not start CouchDB, Object Storage, PostgREST, or the P2P signalling
relay. Use the documented service commands or the complete
local-suite:services wrapper when required.`;
}