Makros: GET/POST-Anfragen mit JSON-Ergebnis und FOR-Iteration

Erweitert die Makro-Sprache um Netzwerkzugriff: "x = GET(url)"/
"x = POST(url; body)" binden die geparste JSON-Antwort an eine Variable;
FOR item IN x DO ... END iteriert, wenn x ein JSON-Array ist, über dessen
Elemente; JSONGET(wert; pfad) liest ein Feld heraus (punktgetrennt,
Zahl = Array-Index) und funktioniert – anders als GET/POST – überall in
einem Ausdruck verschachtelt, nicht nur als ganze Zuweisung.

Da ein echter Netzwerkaufruf nicht synchron laufen kann, ohne den Tab
einzufrieren, bekommt der Makro-Interpreter einen separaten asynchronen
Zweig (execBlockAsync/execStatementAsync); der bestehende synchrone
Interpreter, den auch gewöhnliche "Mit Schleife"-Funktionen mitten in der
synchronen Neuberechnung nutzen, bleibt unangetastet. Netzwerkaufrufe
werden pro Makro einmalig mit Zielname bestätigt (dauerhaft gemerkt); die
bestehende "ein Lauf = ein Undo-Schritt"-Garantie, die bislang auf
Synchronität beruhte, wird jetzt explizit wiederhergestellt statt sich
weiter auf die Zeit-Koaleszenz zu verlassen.

Zwei echte Lücken erst beim eigenen Live-Test gefunden und behoben: (1)
JSONGET direkt in eine Aktion verschachtelt (statt erst einer Variable
zugewiesen) scheiterte zunächst an #NAME?; (2) JSONGET(...) direkt als
FOR-Quelle (ohne Zwischenvariable) scheiterte sowohl an der
Speicher-Zeit-Prüfung als auch zur Laufzeit, da substitute() einen
Funktionsaufruf nie auswertet.

Live gegen eine neue same-origin-Fixture (public/test-fixtures/sample.json)
verifiziert: echter GET, echter Bestätigungsdialog, FOR/JSONGET über die
echte Antwort, kein erneuter Dialog beim zweiten Lauf.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-09 11:10:19 +02:00
co-authored by Claude Sonnet 5
parent a59fda25b0
commit 3ae3d81066
15 changed files with 1043 additions and 101 deletions
+178 -26
View File
@@ -777,6 +777,17 @@ describe('custom functions (document-level library)', () => {
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects a body calling GET/POST/JSONGET -- macro-only, in both an expression-kind and a block-kind function', () => {
const s = fresh();
for (const name of ['GET', 'POST', 'JSONGET']) {
const expr = s.addCustomFunction('F1', ['x'], `${name}(x)`, 'expression') as { error: string };
expect(expr.error).toBeTruthy();
const block = s.addCustomFunction('F2', ['x'], `r = ${name}(x) RETURN r`, 'block') as { error: string };
expect(block.error).toBeTruthy();
}
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects a name colliding with another custom function (case-insensitive)', () => {
const s = fresh();
s.addCustomFunction('Marge', ['x'], 'x');
@@ -1444,38 +1455,49 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
).toBeTruthy();
});
it('runMacro: SETVALUE writes a cell, reported as a success with a default message', () => {
it('accepts a JSONGET(...) call used directly as a FOR source (not first bound to a variable) -- found live: this needed its own fix in findInvalidForSource, distinct from the ordinary cell/range/param shape check', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const result = s.addMacro(
'M',
`r = GET("https://x") FOR item IN JSONGET(r; "items") DO SETVALUE(${name}::A1; JSONGET(item; "name")) END`,
);
expect(typeof result).toBe('string');
});
it('runMacro: SETVALUE writes a cell, reported as a success with a default message', async () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const macroId = s.addMacro('Setzen', `SETVALUE(${name}::A1; 42)`) as string;
const result = s.runMacro(macroId);
const result = await s.runMacro(macroId);
expect(result).toEqual({ ok: true, message: 'Makro ausgeführt.' });
expect(s.cellInput(id, 0, 0)).toBe('42');
});
it('runMacro: a RETURNed string becomes the success message instead of the default', () => {
it('runMacro: a RETURNed string becomes the success message instead of the default', async () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1) RETURN "fertig"`) as string;
expect(s.runMacro(macroId)).toEqual({ ok: true, message: 'fertig' });
await expect(s.runMacro(macroId)).resolves.toEqual({ ok: true, message: 'fertig' });
});
it('runMacro: SETVALUE can write a live formula (a value starting with "=")', () => {
it('runMacro: SETVALUE can write a live formula (a value starting with "=")', async () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '3');
setCell(s, name, 'A2', '4');
const macroId = s.addMacro('M', `SETVALUE(${name}::A3; "=" & ${name}::A1 & "+" & ${name}::A2)`) as string;
expect(s.runMacro(macroId).ok).toBe(true);
expect((await s.runMacro(macroId)).ok).toBe(true);
expect(s.cellInput(id, 0, 2)).toBe('=3+4');
expect(val(s, name, 'A3')).toBe(7);
});
it('runMacro: INSERTROW performs the same document-wide reference rewrite as the store primitive', () => {
it('runMacro: INSERTROW performs the same document-wide reference rewrite as the store primitive', async () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
@@ -1484,13 +1506,13 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
setCell(s, name, 'B1', `=${name}::A1+${name}::A2`);
const macroId = s.addMacro('Zeile', `INSERTROW("${name}"; 0)`) as string;
expect(s.runMacro(macroId).ok).toBe(true);
expect((await s.runMacro(macroId)).ok).toBe(true);
expect(s.tableById(id)!.rows).toBe(13); // seed default 12 + 1
expect(s.cellInput(id, 0, 1)).toBe('10'); // A1's content slid down to A2
expect(s.cellInput(id, 1, 1)).toBe(`=${name}::A2+${name}::A3`); // the SUM formula's own refs shifted too
});
it('runMacro: a structural action mid-run does not leave later reads/writes stale (the thin-host-proxy fix)', () => {
it('runMacro: a structural action mid-run does not leave later reads/writes stale (the thin-host-proxy fix)', async () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
@@ -1505,12 +1527,12 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
`SETVALUE(${name}::B1; 1) INSERTROW("${name}"; 0) SETVALUE(${name}::C1; ${name}::A2 + 100)`,
) as string;
expect(s.runMacro(macroId)).toEqual({ ok: true, message: 'Makro ausgeführt.' });
await expect(s.runMacro(macroId)).resolves.toEqual({ ok: true, message: 'Makro ausgeführt.' });
expect(s.cellInput(id, 1, 1)).toBe('1'); // B1's write, shifted to B2 by the insert
expect(val(s, name, 'C1')).toBe(105); // reads A2 (=5, shifted from A1) fresh, post-insert
});
it('runMacro: ADDTABLE/RENAMETABLE/DELETETABLE round-trip', () => {
it('runMacro: ADDTABLE/RENAMETABLE/DELETETABLE round-trip', async () => {
const s = fresh();
const sheetId = s.doc.activeSheetId;
const sheetName = s.sheetById(sheetId)!.name;
@@ -1520,11 +1542,11 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
) as string;
const before = s.tablesOf(sheetId).length;
expect(s.runMacro(macroId).ok).toBe(true);
expect((await s.runMacro(macroId)).ok).toBe(true);
expect(s.tablesOf(sheetId).length).toBe(before); // added then deleted -- net zero
});
it('runMacro: ADDTABLE on a DIFFERENT sheet does not yank the active sheet/selection there (found live: addTable() itself navigates, a macro must undo just that incidental side effect)', () => {
it('runMacro: ADDTABLE on a DIFFERENT sheet does not yank the active sheet/selection there (found live: addTable() itself navigates, a macro must undo just that incidental side effect)', async () => {
const s = fresh();
const startSheetId = s.doc.activeSheetId;
const startSelection = s.doc.selection;
@@ -1533,14 +1555,14 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
expect(s.doc.activeSheetId).toBe(startSheetId);
const macroId = s.addMacro('M', 'ADDTABLE("Ziel"; "Neu")') as string;
expect(s.runMacro(macroId).ok).toBe(true);
expect((await s.runMacro(macroId)).ok).toBe(true);
expect(s.doc.activeSheetId).toBe(startSheetId); // NOT switched to "Ziel"
expect(s.doc.selection).toEqual(startSelection);
expect(s.tablesOf(otherSheetId).map((t) => t.name)).toContain('Neu'); // the mutation itself still happened
});
it('runMacro: ADDSHEET/RENAMESHEET/DELETESHEET round-trip', () => {
it('runMacro: ADDSHEET/RENAMESHEET/DELETESHEET round-trip', async () => {
const s = fresh();
const before = s.doc.sheets.length;
const macroId = s.addMacro(
@@ -1548,50 +1570,50 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
'ADDSHEET("MakroBlatt") RENAMESHEET("MakroBlatt"; "Umbenannt") DELETESHEET("Umbenannt")',
) as string;
expect(s.runMacro(macroId).ok).toBe(true);
expect((await s.runMacro(macroId)).ok).toBe(true);
expect(s.doc.sheets.length).toBe(before);
});
it('runMacro: DELETESHEET refuses the last sheet, surfaced as a real failure not a silent no-op', () => {
it('runMacro: DELETESHEET refuses the last sheet, surfaced as a real failure not a silent no-op', async () => {
const s = fresh();
// collapse to exactly one sheet first
while (s.doc.sheets.length > 1) s.deleteSheet(s.doc.sheets[s.doc.sheets.length - 1].id);
const onlySheetName = s.doc.sheets[0].name;
const macroId = s.addMacro('M', `DELETESHEET("${onlySheetName}")`) as string;
const result = s.runMacro(macroId);
const result = await s.runMacro(macroId);
expect(result.ok).toBe(false);
expect(s.doc.sheets).toHaveLength(1);
});
it('runMacro: an unknown table reference fails cleanly, no throw escapes', () => {
it('runMacro: an unknown table reference fails cleanly, no throw escapes', async () => {
const s = fresh();
const macroId = s.addMacro('M', 'INSERTROW("Nirgendwo"; 0)') as string;
const result = s.runMacro(macroId);
const result = await s.runMacro(macroId);
expect(result.ok).toBe(false);
expect(result.message).toMatch(/unbekannte Tabelle/);
});
it('runMacro: an unknown macro id fails cleanly', () => {
it('runMacro: an unknown macro id fails cleanly', async () => {
const s = fresh();
expect(s.runMacro('does-not-exist').ok).toBe(false);
expect((await s.runMacro('does-not-exist')).ok).toBe(false);
});
it('updateMacro changes the body; deleteMacro removes it', () => {
it('updateMacro changes the body; deleteMacro removes it', async () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1)`) as string;
s.updateMacro(macroId, 'M', `SETVALUE(${name}::A1; 2)`);
s.runMacro(macroId);
await s.runMacro(macroId);
expect(s.cellInput(id, 0, 0)).toBe('2');
s.deleteMacro(macroId);
expect(s.doc.macros).toHaveLength(0);
});
it('a whole synchronous macro run (SETVALUE + a structural action) coalesces into exactly one undo step', () => {
it('a whole synchronous macro run (SETVALUE + a structural action) coalesces into exactly one undo step', async () => {
vi.useFakeTimers();
try {
const s = fresh();
@@ -1600,7 +1622,7 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1) INSERTROW("${name}"; 0)`) as string;
vi.advanceTimersByTime(600); // the addTable + addMacro setup settles first
s.runMacro(macroId);
await s.runMacro(macroId);
vi.advanceTimersByTime(600);
expect(s.cellInput(id, 0, 1)).toBe('1'); // shifted down by the insert
expect(s.tableById(id)!.rows).toBe(13);
@@ -1615,6 +1637,119 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
}
});
describe('GET/POST: confirmation gate + persistence, and the undo-invariant fix', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('confirms the first GET to a new host, persists it, and does not re-prompt for a second run to the same host', async () => {
const confirmMock = vi.fn(() => true);
vi.stubGlobal('confirm', confirmMock);
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: true, json: async () => ({ v: 1 }) })),
);
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const macroId = s.addMacro('M', `r = GET("https://api.example.com/x") SETVALUE(${name}::A1; JSONGET(r; "v"))`) as string;
expect((await s.runMacro(macroId)).ok).toBe(true);
expect(confirmMock).toHaveBeenCalledTimes(1);
expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual(['api.example.com']);
expect((await s.runMacro(macroId)).ok).toBe(true);
expect(confirmMock).toHaveBeenCalledTimes(1); // NOT called again -- already approved
});
it('a different host on the same macro prompts again', async () => {
vi.stubGlobal('confirm', vi.fn(() => true));
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => 1 })));
const s = fresh();
const macroId = s.addMacro('M', 'a = GET("https://one.example.com") b = GET("https://two.example.com")') as string;
expect((await s.runMacro(macroId)).ok).toBe(true);
expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual([
'one.example.com',
'two.example.com',
]);
});
it('declining the confirmation aborts the macro and does NOT persist the host', async () => {
const confirmMock = vi.fn(() => false);
vi.stubGlobal('confirm', confirmMock);
vi.stubGlobal('fetch', vi.fn());
const s = fresh();
const macroId = s.addMacro('M', 'r = GET("https://api.example.com/x")') as string;
const result = await s.runMacro(macroId);
expect(result.ok).toBe(false);
expect(result.message).toMatch(/abgelehnt/);
expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts ?? []).toEqual([]);
});
it('a non-2xx response aborts the macro cleanly', async () => {
vi.stubGlobal('confirm', vi.fn(() => true));
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: false, status: 500, statusText: 'Internal Server Error' })),
);
const s = fresh();
const macroId = s.addMacro('M', 'r = GET("https://api.example.com/x")') as string;
const result = await s.runMacro(macroId);
expect(result.ok).toBe(false);
expect(result.message).toMatch(/500/);
});
it('the mandatory test: SETVALUE -> a slow GET -> SETVALUE still produces exactly one undo step, proving the macroRunActive fix', async () => {
vi.useFakeTimers();
try {
vi.stubGlobal('confirm', vi.fn(() => true));
vi.stubGlobal(
'fetch',
vi.fn(
() =>
new Promise((resolve) =>
// longer than HISTORY_COALESCE_MS (150ms) -- without the
// macroRunActive fix, the debounced auto-commit would fire
// *during* this wait and split the run into two undo steps
setTimeout(() => resolve({ ok: true, json: async () => 7 }), 300),
),
),
);
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const macroId = s.addMacro(
'M',
`SETVALUE(${name}::A1; 1) r = GET("https://api.example.com/x") SETVALUE(${name}::A2; r)`,
) as string;
vi.advanceTimersByTime(600); // let the addTable + addMacro setup settle into its OWN step first
const runPromise = s.runMacro(macroId);
await vi.advanceTimersByTimeAsync(300); // let the mocked fetch's internal setTimeout resolve
await runPromise;
expect(s.cellInput(id, 0, 0)).toBe('1');
expect(s.cellInput(id, 0, 1)).toBe('7');
// If the run had split into two undo steps (the bug this fix
// prevents), this single undo() would only revert the *second*
// SETVALUE, leaving A1 still '1' -- a direct, unambiguous proof of
// "one run = one step" without needing to peek at undoStack.length.
// (The table itself survives too, proving it settled as its own
// separate step beforehand, not bundled into the macro's step.)
s.undo();
expect(s.tableById(id)).toBeDefined();
expect(s.cellInput(id, 0, 0)).toBe('');
expect(s.cellInput(id, 0, 1)).toBe('');
} finally {
vi.runOnlyPendingTimers();
vi.useRealTimers();
}
});
});
it('JSON export/import round-trips macros byte-for-byte', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
@@ -1625,6 +1760,23 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
const reimported = parseDocument(json);
expect(reimported.macros).toEqual(s.doc.macros);
});
it('JSON export/import round-trips approvedHosts too', async () => {
vi.stubGlobal('confirm', vi.fn(() => true));
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => 1 })));
try {
const s = fresh();
const macroId = s.addMacro('M', 'r = GET("https://api.example.com/x")') as string;
await s.runMacro(macroId);
expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual(['api.example.com']);
const json = serializeDocument(s.doc);
const reimported = parseDocument(json);
expect(reimported.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual(['api.example.com']);
} finally {
vi.unstubAllGlobals();
}
});
});
describe('custom functions: "block" kind (FOR/IF/RETURN "code")', () => {