config: add revision reads and conflict recovery
This commit is contained in:
@@ -56,6 +56,36 @@ impl SqliteWorkspaceStore {
|
|||||||
self.with_conn(|conn| load_state(conn, workspace_id))
|
self.with_conn(|conn| load_state(conn, workspace_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn load_workspace_config_revision(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
revision: u64,
|
||||||
|
) -> Result<Option<ConfigTreeSnapshot>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let manifest = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT tree_digest, manifest_json FROM workspace_config_tree_revisions WHERE workspace_id = ?1 AND revision = ?2",
|
||||||
|
params![workspace_id, revision as i64],
|
||||||
|
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let Some((stored_digest, manifest_json)) = manifest else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let entries: std::collections::BTreeMap<VirtualPath, ConfigEntry> =
|
||||||
|
serde_json::from_str(&manifest_json)
|
||||||
|
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
|
||||||
|
let snapshot = ConfigTreeSnapshot::from_entries(revision, entries.into_values())
|
||||||
|
.map_err(config_error)?;
|
||||||
|
if snapshot.digest != stored_digest {
|
||||||
|
return Err(Error::RegistryInconsistency(format!(
|
||||||
|
"virtual config revision digest mismatch for Workspace {workspace_id} revision {revision}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Some(snapshot))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn evaluate_workspace_config_candidate(
|
pub fn evaluate_workspace_config_candidate(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -475,6 +505,59 @@ mod tests {
|
|||||||
assert!(matches!(error, Error::WorkspaceConfigConflict(_)));
|
assert!(matches!(error, Error::WorkspaceConfigConflict(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn committed_revision_remains_retrievable_after_later_commit() {
|
||||||
|
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||||
|
store.upsert_workspace(&workspace()).await.unwrap();
|
||||||
|
let empty = ConfigTreeSnapshot::empty();
|
||||||
|
let contract = ToolchainContract::new(
|
||||||
|
DEFAULT_SCHEMA_VERSION,
|
||||||
|
vec![path(DEFAULT_CONFIG_ENTRYPOINT)],
|
||||||
|
DEFAULT_IMPORT_POLICY_VERSION,
|
||||||
|
);
|
||||||
|
let first = store
|
||||||
|
.evaluate_and_commit_workspace_config(
|
||||||
|
"w-config",
|
||||||
|
&ConfigCommitRequest {
|
||||||
|
base_revision: 0,
|
||||||
|
base_digest: empty.digest,
|
||||||
|
changes: vec![ConfigTreeChange::Create {
|
||||||
|
path: path(DEFAULT_CONFIG_ENTRYPOINT),
|
||||||
|
content_type: ConfigContentType::Decodal,
|
||||||
|
content: "{ answer = 1; }".into(),
|
||||||
|
}],
|
||||||
|
entrypoints: contract.entrypoints.clone(),
|
||||||
|
toolchain_fingerprint: contract.fingerprint.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let entry = first
|
||||||
|
.snapshot
|
||||||
|
.get(&path(DEFAULT_CONFIG_ENTRYPOINT))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.evaluate_and_commit_workspace_config(
|
||||||
|
"w-config",
|
||||||
|
&ConfigCommitRequest {
|
||||||
|
base_revision: first.snapshot.revision,
|
||||||
|
base_digest: first.snapshot.digest.clone(),
|
||||||
|
changes: vec![ConfigTreeChange::Update {
|
||||||
|
path: path(DEFAULT_CONFIG_ENTRYPOINT),
|
||||||
|
expected_digest: entry.content_digest.clone(),
|
||||||
|
content: "{ answer = 2; }".into(),
|
||||||
|
}],
|
||||||
|
entrypoints: contract.entrypoints,
|
||||||
|
toolchain_fingerprint: contract.fingerprint,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let revision = store
|
||||||
|
.load_workspace_config_revision("w-config", 1)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(revision, first.snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn commit_rejects_mismatched_toolchain_fingerprint() {
|
async fn commit_rejects_mismatched_toolchain_fingerprint() {
|
||||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||||
|
|||||||
@@ -1151,6 +1151,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||||||
"/api/w/{workspace_id}/config/source-tree/commit",
|
"/api/w/{workspace_id}/config/source-tree/commit",
|
||||||
post(scoped_commit_workspace_config_tree),
|
post(scoped_commit_workspace_config_tree),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/config/source-tree/revisions/{revision}",
|
||||||
|
get(scoped_get_workspace_config_revision),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/config/source-tree/entries/{*path}",
|
"/api/w/{workspace_id}/config/source-tree/entries/{*path}",
|
||||||
get(scoped_get_workspace_config_entry),
|
get(scoped_get_workspace_config_entry),
|
||||||
@@ -2441,6 +2445,24 @@ async fn scoped_update_workspace_settings(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WorkspaceConfigRevisionPath {
|
||||||
|
workspace_id: String,
|
||||||
|
revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_get_workspace_config_revision(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<WorkspaceConfigRevisionPath>,
|
||||||
|
) -> ApiResult<Json<ConfigTreeSnapshot>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
let snapshot = api
|
||||||
|
.config_store
|
||||||
|
.load_workspace_config_revision(&path.workspace_id, path.revision)?
|
||||||
|
.ok_or_else(|| ApiError::from(Error::InvalidRecordId(path.revision.to_string())))?;
|
||||||
|
Ok(Json(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct WorkspaceConfigEntryPath {
|
struct WorkspaceConfigEntryPath {
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
let renamePath = $state("");
|
let renamePath = $state("");
|
||||||
let baseSnapshot = $state<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
|
let baseSnapshot = $state<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
|
||||||
let preflightDigest = $state("");
|
let preflightDigest = $state("");
|
||||||
|
let conflict = $state(false);
|
||||||
|
let candidateContract = $state<WorkspaceConfigTreeResponse["contract"] | null>(null);
|
||||||
let toolchain: ConfigSourceToolchain | null = null;
|
let toolchain: ConfigSourceToolchain | null = null;
|
||||||
|
|
||||||
const paths = $derived(
|
const paths = $derived(
|
||||||
@@ -58,6 +60,8 @@
|
|||||||
draftChanges = [];
|
draftChanges = [];
|
||||||
renamePath = selectedPath;
|
renamePath = selectedPath;
|
||||||
diagnostics = [];
|
diagnostics = [];
|
||||||
|
conflict = false;
|
||||||
|
candidateContract = null;
|
||||||
status = treeState.snapshot.revision === 0
|
status = treeState.snapshot.revision === 0
|
||||||
? "No committed sources yet. Create workspace.dcdl to begin."
|
? "No committed sources yet. Create workspace.dcdl to begin."
|
||||||
: `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
: `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
||||||
@@ -73,6 +77,8 @@
|
|||||||
if (treeState) treeState = { ...treeState, snapshot: candidate };
|
if (treeState) treeState = { ...treeState, snapshot: candidate };
|
||||||
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||||
preflightDigest = "";
|
preflightDigest = "";
|
||||||
|
candidateContract = null;
|
||||||
|
conflict = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function select(path: string) {
|
async function select(path: string) {
|
||||||
@@ -142,13 +148,16 @@
|
|||||||
const candidate = await previewConfigTree(workspaceId, {
|
const candidate = await previewConfigTree(workspaceId, {
|
||||||
changes: draftChanges,
|
changes: draftChanges,
|
||||||
entrypoints: entrypoints(),
|
entrypoints: entrypoints(),
|
||||||
toolchain_fingerprint: treeState.contract.fingerprint,
|
|
||||||
});
|
});
|
||||||
|
await toolchain?.evaluate(candidate.contract);
|
||||||
diagnostics = [];
|
diagnostics = [];
|
||||||
|
candidateContract = candidate.contract;
|
||||||
preflightDigest = candidate.snapshot.digest;
|
preflightDigest = candidate.snapshot.digest;
|
||||||
status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`;
|
status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status = String(error);
|
const message = String(error);
|
||||||
|
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
|
||||||
|
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -161,7 +170,7 @@
|
|||||||
status = "No draft changes to commit.";
|
status = "No draft changes to commit.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (preflightDigest !== treeState.snapshot.digest) {
|
if (preflightDigest !== treeState.snapshot.digest || !candidateContract) {
|
||||||
status = "Preview the complete candidate successfully before Commit.";
|
status = "Preview the complete candidate successfully before Commit.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,11 +180,13 @@
|
|||||||
base_revision: baseRevision,
|
base_revision: baseRevision,
|
||||||
base_digest: baseDigest,
|
base_digest: baseDigest,
|
||||||
changes: draftChanges,
|
changes: draftChanges,
|
||||||
entrypoints: entrypoints(),
|
entrypoints: candidateContract.entrypoints,
|
||||||
toolchain_fingerprint: treeState.contract.fingerprint,
|
toolchain_fingerprint: candidateContract.fingerprint,
|
||||||
});
|
});
|
||||||
draftChanges = [];
|
draftChanges = [];
|
||||||
preflightDigest = "";
|
preflightDigest = "";
|
||||||
|
candidateContract = null;
|
||||||
|
conflict = false;
|
||||||
baseSnapshot = structuredClone(treeState.snapshot);
|
baseSnapshot = structuredClone(treeState.snapshot);
|
||||||
baseRevision = treeState.snapshot.revision;
|
baseRevision = treeState.snapshot.revision;
|
||||||
baseDigest = treeState.snapshot.digest;
|
baseDigest = treeState.snapshot.digest;
|
||||||
@@ -184,12 +195,39 @@
|
|||||||
diagnostics = [];
|
diagnostics = [];
|
||||||
status = `Committed revision ${treeState.snapshot.revision}.`;
|
status = `Committed revision ${treeState.snapshot.revision}.`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status = String(error);
|
const message = String(error);
|
||||||
|
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
|
||||||
|
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function discardAndReload() {
|
||||||
|
draftChanges = [];
|
||||||
|
source = "";
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadAndReapply() {
|
||||||
|
if (!toolchain || !treeState) return;
|
||||||
|
const localCandidate = structuredClone(treeState.snapshot);
|
||||||
|
const remote = await fetchConfigTree(workspaceId);
|
||||||
|
baseSnapshot = structuredClone(remote.snapshot);
|
||||||
|
baseRevision = remote.snapshot.revision;
|
||||||
|
baseDigest = remote.snapshot.digest;
|
||||||
|
await toolchain.setSnapshot(remote.snapshot);
|
||||||
|
draftChanges = await toolchain.changesBetween(remote.snapshot, localCandidate);
|
||||||
|
const candidate = await toolchain.applyChanges(draftChanges);
|
||||||
|
treeState = { ...remote, snapshot: candidate };
|
||||||
|
selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? "";
|
||||||
|
source = selectedPath ? candidate.entries[selectedPath].content : "";
|
||||||
|
conflict = false;
|
||||||
|
preflightDigest = "";
|
||||||
|
candidateContract = null;
|
||||||
|
status = "Local candidate reapplied to the latest revision. Preview again before Commit.";
|
||||||
|
}
|
||||||
|
|
||||||
function createEntry() {
|
function createEntry() {
|
||||||
const path = newPath.trim();
|
const path = newPath.trim();
|
||||||
if (!path || treeState?.snapshot.entries[path]) return;
|
if (!path || treeState?.snapshot.entries[path]) return;
|
||||||
@@ -210,6 +248,8 @@
|
|||||||
treeState = { ...treeState, snapshot: candidate };
|
treeState = { ...treeState, snapshot: candidate };
|
||||||
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||||
preflightDigest = "";
|
preflightDigest = "";
|
||||||
|
candidateContract = null;
|
||||||
|
conflict = false;
|
||||||
selectedPath = Object.keys(candidate.entries).toSorted()[0] ?? "";
|
selectedPath = Object.keys(candidate.entries).toSorted()[0] ?? "";
|
||||||
source = selectedPath ? candidate.entries[selectedPath].content : "";
|
source = selectedPath ? candidate.entries[selectedPath].content : "";
|
||||||
renamePath = selectedPath;
|
renamePath = selectedPath;
|
||||||
@@ -231,6 +271,8 @@
|
|||||||
treeState = { ...treeState, snapshot: candidate };
|
treeState = { ...treeState, snapshot: candidate };
|
||||||
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||||
preflightDigest = "";
|
preflightDigest = "";
|
||||||
|
candidateContract = null;
|
||||||
|
conflict = false;
|
||||||
selectedPath = to;
|
selectedPath = to;
|
||||||
source = candidate.entries[to]?.content ?? "";
|
source = candidate.entries[to]?.content ?? "";
|
||||||
status = `Rename to ${to} staged. Preview and Commit to persist.`;
|
status = `Rename to ${to} staged. Preview and Commit to persist.`;
|
||||||
@@ -282,6 +324,12 @@
|
|||||||
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)}
|
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)}
|
||||||
/>
|
/>
|
||||||
<p class="config-source-status" aria-live="polite">{status}</p>
|
<p class="config-source-status" aria-live="polite">{status}</p>
|
||||||
|
{#if conflict}
|
||||||
|
<div class="config-source-conflict" role="alert">
|
||||||
|
<button type="button" onclick={discardAndReload}>Discard local candidate and reload</button>
|
||||||
|
<button type="button" onclick={reloadAndReapply}>Reload and reapply local candidate</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if diagnostics.length > 0}
|
{#if diagnostics.length > 0}
|
||||||
<ol class="config-source-diagnostics">
|
<ol class="config-source-diagnostics">
|
||||||
{#each diagnostics as diagnostic}
|
{#each diagnostics as diagnostic}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
ConfigCommitRequest,
|
ConfigCommitRequest,
|
||||||
ConfigEntry,
|
ConfigEntry,
|
||||||
|
ConfigPreviewRequest,
|
||||||
|
ConfigTreeSnapshot,
|
||||||
EvaluatedConfigCandidate,
|
EvaluatedConfigCandidate,
|
||||||
WorkspaceConfigTreeResponse,
|
WorkspaceConfigTreeResponse,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
@@ -41,9 +43,21 @@ export async function fetchConfigEntry(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchConfigRevision(
|
||||||
|
workspaceId: string,
|
||||||
|
revision: number,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): Promise<ConfigTreeSnapshot> {
|
||||||
|
return await readJson(
|
||||||
|
await fetcher(`${sourceTreeUrl(workspaceId)}/revisions/${revision}`, {
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function previewConfigTree(
|
export async function previewConfigTree(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
request: Omit<ConfigCommitRequest, "base_revision" | "base_digest">,
|
request: ConfigPreviewRequest,
|
||||||
fetcher: typeof fetch = fetch,
|
fetcher: typeof fetch = fetch,
|
||||||
): Promise<EvaluatedConfigCandidate> {
|
): Promise<EvaluatedConfigCandidate> {
|
||||||
return await readJson(
|
return await readJson(
|
||||||
|
|||||||
@@ -631,6 +631,22 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
.config-source-conflict {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-top: 1px solid var(--danger);
|
||||||
|
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||||
|
}
|
||||||
|
.config-source-conflict button {
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--danger);
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.config-source-diagnostics {
|
.config-source-diagnostics {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { assert, assertEquals } from "jsr:@std/assert";
|
|||||||
import {
|
import {
|
||||||
commitConfigTree,
|
commitConfigTree,
|
||||||
fetchConfigEntry,
|
fetchConfigEntry,
|
||||||
|
fetchConfigRevision,
|
||||||
fetchConfigTree,
|
fetchConfigTree,
|
||||||
previewConfigTree,
|
previewConfigTree,
|
||||||
} from "../../src/lib/workspace/config-source/api.ts";
|
} from "../../src/lib/workspace/config-source/api.ts";
|
||||||
@@ -23,8 +24,9 @@ Deno.test("config source API stays workspace-scoped and separates preview from c
|
|||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
|
|
||||||
await fetchConfigTree("w/one", fetcher);
|
await fetchConfigTree("w/one", fetcher);
|
||||||
|
await fetchConfigRevision("w/one", 7, fetcher);
|
||||||
await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher);
|
await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher);
|
||||||
await previewConfigTree("w/one", { changes: [], entrypoints: [], toolchain_fingerprint: "sha256:toolchain" }, fetcher);
|
await previewConfigTree("w/one", { changes: [], entrypoints: [] }, fetcher);
|
||||||
await commitConfigTree("w/one", {
|
await commitConfigTree("w/one", {
|
||||||
base_revision: 4,
|
base_revision: 4,
|
||||||
base_digest: "sha256:base",
|
base_digest: "sha256:base",
|
||||||
@@ -35,14 +37,15 @@ Deno.test("config source API stays workspace-scoped and separates preview from c
|
|||||||
|
|
||||||
assertEquals(calls.map((call) => call.url), [
|
assertEquals(calls.map((call) => call.url), [
|
||||||
"/api/w/w%2Fone/config/source-tree",
|
"/api/w/w%2Fone/config/source-tree",
|
||||||
|
"/api/w/w%2Fone/config/source-tree/revisions/7",
|
||||||
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
|
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
|
||||||
"/api/w/w%2Fone/config/source-tree/preview",
|
"/api/w/w%2Fone/config/source-tree/preview",
|
||||||
"/api/w/w%2Fone/config/source-tree/commit",
|
"/api/w/w%2Fone/config/source-tree/commit",
|
||||||
]);
|
]);
|
||||||
assertEquals(calls[2].init?.method, "POST");
|
|
||||||
assertEquals(calls[3].init?.method, "POST");
|
assertEquals(calls[3].init?.method, "POST");
|
||||||
|
assertEquals(calls[4].init?.method, "POST");
|
||||||
assert(
|
assert(
|
||||||
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'),
|
String(calls[4].init?.body).includes('"base_digest":"sha256:base"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,7 +56,7 @@ Deno.test("config source API surfaces failed evaluation instead of treating it a
|
|||||||
)) as typeof fetch;
|
)) as typeof fetch;
|
||||||
let message = "";
|
let message = "";
|
||||||
try {
|
try {
|
||||||
await previewConfigTree("w", { changes: [], entrypoints: [], toolchain_fingerprint: "sha256:toolchain" }, fetcher);
|
await previewConfigTree("w", { changes: [], entrypoints: [] }, fetcher);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message = String(error);
|
message = String(error);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user