Files
qinglong/packages/ql3-runtime-core/test/pluginPackageAutomationPublication.test.cjs

568 lines
16 KiB
JavaScript

const assert = require('node:assert/strict');
const { createHash } = require('node:crypto');
const { test } = require('node:test');
const {
assertPluginPackageAutomationPublicationSuccessor,
createInitialPluginPackageAutomationPublication,
createNextPluginPackageAutomationPublication,
createPluginPackageAutomationLifecyclePublication,
InvalidPluginPackageAutomationPublicationError,
normalizePluginPackageAutomationPublication,
PluginPackageAutomationPublicationConflictError,
PluginPackageAutomationPublicationCoordinator,
PluginPackageAutomationPublicationRecoveryCoordinator,
PluginPackageAutomationPublicationUnavailableError,
pluginPackageAutomationDefinitionsFromRevision,
pluginPackageAutomationPublicationDigest,
} = require('../dist/plugin-package/pluginPackageAutomationPublication');
const {
pluginPackageTaskReconciliationFixture,
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
function digest(value) {
return createHash('sha256').update(value).digest('hex');
}
function automationFixture(namespace = 'automation-publication') {
return pluginPackageTaskReconciliationFixture(namespace, {
workflows: [
{
schema: 'qinglong/plugin-package-workflow-resource@v1',
id: 'daily',
name: 'Daily workflow',
enabled: true,
steps: [
{
id: 'run',
task: 'alpha',
needs: [],
},
],
},
],
prompts: [
{
schema: 'qinglong/plugin-package-prompt-resource@v1',
id: 'greeting',
name: 'Greeting prompt',
template: 'Hello {{name}}',
parameters: [
{
name: 'name',
required: true,
},
],
},
],
});
}
function automationCoordinatorAuthorities(
namespace = 'automation-publication-coordinator',
) {
const first = automationFixture(namespace);
let generation = first.revision.generation;
let head = null;
let observations = 0;
const revisions = new Map([
[first.revision.generation.generationDigest, first.revision],
]);
const publications = new Map();
const generationSource = {
async findActiveResourceGeneration() {
observations += 1;
return generation;
},
};
const materializedRepository = {
async find(generationDigest) {
return revisions.get(generationDigest) ?? null;
},
};
const repository = {
async findCurrent() {
return head;
},
async findByDigest(publicationDigest) {
return publications.get(publicationDigest) ?? null;
},
async publish(publication) {
const existing = publications.get(publication.publicationDigest);
if (existing) return { status: 'existing', publication: existing };
publications.set(publication.publicationDigest, publication);
head = publication;
return { status: 'created', publication };
},
};
const coordinator = new PluginPackageAutomationPublicationCoordinator({
generationSource,
materializedRepository,
repository,
taskSpecSemanticRegistry: first.registry,
now: () => 2_000 + observations,
});
return {
first,
coordinator,
generationSource,
materializedRepository,
repository,
revisions,
get generation() {
return generation;
},
set generation(value) {
generation = value;
},
get head() {
return head;
},
};
}
test('creates one deterministic publication for Workflow and Prompt definitions', () => {
const value = automationFixture();
const definitions = pluginPackageAutomationDefinitionsFromRevision(
value.revision,
value.registry,
);
assert.deepEqual(
definitions.workflows.map(({ id }) => id),
['daily'],
);
assert.deepEqual(
definitions.prompts.map(({ id }) => id),
['greeting'],
);
const publication = createInitialPluginPackageAutomationPublication(
value.revision,
value.registry,
1_000,
);
assert.equal(publication.state, 'active');
assert.equal(publication.version, 1);
assert.equal(publication.previousPublicationDigest, null);
assert.equal(publication.lifecycleEventDigest, null);
assert.equal(
publication.target.materializedRevisionDigest,
value.revision.revisionDigest,
);
assert.equal(
publication.publicationDigest,
pluginPackageAutomationPublicationDigest(publication),
);
assert.deepEqual(
normalizePluginPackageAutomationPublication(publication),
publication,
);
assert.equal(
createInitialPluginPackageAutomationPublication(
value.revision,
value.registry,
1_000,
).publicationDigest,
publication.publicationDigest,
);
});
test('withdraws and restores the same immutable definitions through a digest chain', () => {
const value = automationFixture('automation-lifecycle');
const initial = createInitialPluginPackageAutomationPublication(
value.revision,
value.registry,
1_000,
);
const disabledEventDigest = digest('disabled');
const withdrawn = createPluginPackageAutomationLifecyclePublication({
previous: initial,
state: 'withdrawn',
lifecycleEventDigest: disabledEventDigest,
publishedAtMs: 1_001,
});
assert.equal(withdrawn.state, 'withdrawn');
assert.equal(withdrawn.version, 2);
assert.equal(
withdrawn.previousPublicationDigest,
initial.publicationDigest,
);
assert.equal(withdrawn.lifecycleEventDigest, disabledEventDigest);
assert.deepEqual(withdrawn.definitions, initial.definitions);
const enabledEventDigest = digest('enabled');
const restored = createPluginPackageAutomationLifecyclePublication({
previous: withdrawn,
state: 'active',
lifecycleEventDigest: enabledEventDigest,
publishedAtMs: 1_002,
});
assert.equal(restored.state, 'active');
assert.equal(restored.version, 3);
assert.equal(
restored.previousPublicationDigest,
withdrawn.publicationDigest,
);
assert.equal(restored.lifecycleEventDigest, enabledEventDigest);
assert.deepEqual(restored.definitions, initial.definitions);
});
test('replaces one active publication with the next materialized generation', () => {
const first = automationFixture('automation-upgrade');
const initial = createInitialPluginPackageAutomationPublication(
first.revision,
first.registry,
1_000,
);
const second = pluginPackageTaskReconciliationFixture(first.namespace, {
previous: first,
tasks: [
['alpha', 'alpha-v2'],
['beta', 'beta'],
],
workflows: [
{
schema: 'qinglong/plugin-package-workflow-resource@v1',
id: 'hourly',
name: 'Hourly workflow',
enabled: true,
steps: [{ id: 'run', task: 'alpha', needs: [] }],
},
],
prompts: [
{
schema: 'qinglong/plugin-package-prompt-resource@v1',
id: 'summary',
name: 'Summary prompt',
template: 'Summarize {{topic}}',
parameters: [{ name: 'topic', required: true }],
},
],
});
const next = createNextPluginPackageAutomationPublication(
second.revision,
second.registry,
initial,
1_100,
);
assert.equal(next.version, 2);
assert.equal(next.state, 'active');
assert.equal(next.lifecycleEventDigest, null);
assert.equal(next.previousPublicationDigest, initial.publicationDigest);
assert.equal(next.target.generation, 2);
assert.deepEqual(
next.definitions.workflows.map(({ id }) => id),
['hourly'],
);
assert.doesNotThrow(() =>
assertPluginPackageAutomationPublicationSuccessor(initial, next),
);
});
test('rejects tampering and invalid lifecycle transitions', () => {
const value = automationFixture('automation-invalid');
const initial = createInitialPluginPackageAutomationPublication(
value.revision,
value.registry,
1_000,
);
assert.throws(
() =>
normalizePluginPackageAutomationPublication({
...initial,
publishedAtMs: 1_001,
}),
/publicationDigest does not match publication/,
);
assert.throws(
() =>
createPluginPackageAutomationLifecyclePublication({
previous: initial,
state: 'active',
lifecycleEventDigest: digest('same-state'),
publishedAtMs: 1_001,
}),
/must toggle its state/,
);
assert.throws(
() =>
createPluginPackageAutomationLifecyclePublication({
previous: initial,
state: 'withdrawn',
lifecycleEventDigest: digest('time-travel'),
publishedAtMs: 999,
}),
/precedes the previous publication/,
);
assert.throws(
() =>
assertPluginPackageAutomationPublicationSuccessor(initial, {
...initial,
version: 2,
previousPublicationDigest: initial.publicationDigest,
publicationDigest: pluginPackageAutomationPublicationDigest({
...initial,
version: 2,
previousPublicationDigest: initial.publicationDigest,
}),
}),
/generation successor is invalid/,
);
});
test('publishes an absent tombstone for a generation without automation', () => {
const value = pluginPackageTaskReconciliationFixture('automation-empty');
assert.equal(
pluginPackageAutomationDefinitionsFromRevision(
value.revision,
value.registry,
),
null,
);
const publication = createInitialPluginPackageAutomationPublication(
value.revision,
value.registry,
1_000,
);
assert.equal(publication.state, 'absent');
assert.equal(publication.version, 1);
assert.deepEqual(publication.definitions, {
workflows: [],
prompts: [],
});
assert.throws(
() =>
createPluginPackageAutomationLifecyclePublication({
previous: publication,
state: 'active',
lifecycleEventDigest: digest('invalid-absent-toggle'),
publishedAtMs: 1_001,
}),
/must toggle its state/,
);
});
test('chains active, absent and active across every Package generation', () => {
const first = automationFixture('automation-tombstone-chain');
const initial = createInitialPluginPackageAutomationPublication(
first.revision,
first.registry,
1_000,
);
const second = pluginPackageTaskReconciliationFixture(first.namespace, {
previous: first,
});
const absent = createNextPluginPackageAutomationPublication(
second.revision,
second.registry,
initial,
1_100,
);
assert.equal(absent.state, 'absent');
assert.equal(absent.target.generation, 2);
assert.deepEqual(absent.definitions, { workflows: [], prompts: [] });
const third = pluginPackageTaskReconciliationFixture(first.namespace, {
previous: second,
workflows: [
{
schema: 'qinglong/plugin-package-workflow-resource@v1',
id: 'restored',
name: 'Restored workflow',
enabled: true,
steps: [{ id: 'run', task: 'alpha', needs: [] }],
},
],
});
const restored = createNextPluginPackageAutomationPublication(
third.revision,
third.registry,
absent,
1_200,
);
assert.equal(restored.state, 'active');
assert.equal(restored.target.generation, 3);
assert.equal(
restored.previousPublicationDigest,
absent.publicationDigest,
);
assert.deepEqual(
restored.definitions.workflows.map(({ id }) => id),
['restored'],
);
});
test('coordinator publishes each materialized generation and preserves lifecycle state on replay', async () => {
const value = automationCoordinatorAuthorities();
const initial = await value.coordinator.publishActive(
value.first.projectId,
value.first.packageName,
);
assert.equal(initial.status, 'current');
assert.equal(initial.publication, 'created');
assert.equal(initial.record.state, 'active');
const withdrawn = createPluginPackageAutomationLifecyclePublication({
previous: initial.record,
state: 'withdrawn',
lifecycleEventDigest: digest('coordinator-disabled'),
publishedAtMs: 2_100,
});
await value.repository.publish(withdrawn);
const replay = await value.coordinator.publishActive(
value.first.projectId,
value.first.packageName,
);
assert.equal(replay.status, 'current');
assert.equal(replay.publication, 'existing');
assert.equal(replay.record.state, 'withdrawn');
const empty = pluginPackageTaskReconciliationFixture(value.first.namespace, {
previous: value.first,
});
value.revisions.set(
empty.revision.generation.generationDigest,
empty.revision,
);
value.generation = empty.revision.generation;
const tombstone = await value.coordinator.publishActive(
value.first.projectId,
value.first.packageName,
);
assert.equal(tombstone.status, 'current');
assert.equal(tombstone.record.state, 'absent');
assert.equal(tombstone.record.version, 3);
assert.deepEqual(tombstone.record.definitions, {
workflows: [],
prompts: [],
});
});
test('coordinator treats a final generation switch as superseded', async () => {
const value = automationCoordinatorAuthorities(
'automation-publication-superseded',
);
let observations = 0;
const source = {
async findActiveResourceGeneration() {
observations += 1;
return observations === 1 ? value.generation : null;
},
};
const coordinator = new PluginPackageAutomationPublicationCoordinator({
generationSource: source,
materializedRepository: value.materializedRepository,
repository: value.repository,
taskSpecSemanticRegistry: value.first.registry,
now: () => 3_000,
});
assert.deepEqual(
await coordinator.publishActive(
value.first.projectId,
value.first.packageName,
),
{
status: 'superseded',
generationDigest:
value.first.revision.generation.generationDigest,
},
);
});
test('bounded automation recovery converges and classifies manual and retry failures', async () => {
const value = automationCoordinatorAuthorities(
'automation-publication-recovery',
);
let pending = [
{
projectId: value.first.projectId,
packageName: value.first.packageName,
},
];
const source = {
async listPendingPage({ limit }) {
return {
candidates: pending.slice(0, limit),
truncated: false,
};
},
};
const publisher = Object.create(
PluginPackageAutomationPublicationCoordinator.prototype,
);
publisher.publishActive = async (projectId, packageName) => {
const result = await value.coordinator.publishActive(
projectId,
packageName,
);
if (result.status === 'current') pending = [];
return result;
};
const recovery =
new PluginPackageAutomationPublicationRecoveryCoordinator({
source,
publisher,
});
assert.deepEqual(
await recovery.recover({ pageSize: 1, maxPages: 1 }),
{
pages: 1,
scanned: 1,
settled: 1,
retry: 0,
manualRequired: 0,
superseded: 0,
remaining: false,
safeToAdmit: true,
},
);
const failures = Object.create(
PluginPackageAutomationPublicationCoordinator.prototype,
);
failures.publishActive = async (_projectId, packageName) => {
if (packageName === 'manual') {
throw new PluginPackageAutomationPublicationConflictError('conflict');
}
throw new PluginPackageAutomationPublicationUnavailableError();
};
const failedRecovery =
new PluginPackageAutomationPublicationRecoveryCoordinator({
source: {
async listPendingPage({ limit }) {
const candidates = [
{ projectId: value.first.projectId, packageName: 'manual' },
{ projectId: value.first.projectId, packageName: 'retry' },
].slice(0, limit);
const truncated = limit < 2;
const last = candidates.at(-1);
return {
candidates,
truncated,
...(truncated
? {
next: {
projectId: last.projectId,
packageName: last.packageName,
},
}
: {}),
};
},
},
publisher: failures,
});
const result = await failedRecovery.recover({
pageSize: 2,
maxPages: 1,
});
assert.equal(result.manualRequired, 1);
assert.equal(result.retry, 1);
assert.equal(result.remaining, true);
assert.equal(result.safeToAdmit, false);
await assert.rejects(
() => failedRecovery.recover({ pageSize: 0 }),
InvalidPluginPackageAutomationPublicationError,
);
});