Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eacbd160d |
+9
-28
@@ -83,9 +83,6 @@ export function checkStatusOf(change: OwedChange): { owner: string; repo: string
|
|||||||
return { owner, repo, sha, revision, status };
|
return { owner, repo, sha, revision, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How many mesh/check writes this module has at the forge at once. */
|
|
||||||
export const WRITES_AT_ONCE = 4;
|
|
||||||
|
|
||||||
/** The one writer of mesh/check: each owed revision of the checker's, in revision order per key. */
|
/** The one writer of mesh/check: each owed revision of the checker's, in revision order per key. */
|
||||||
export class CheckStatuses {
|
export class CheckStatuses {
|
||||||
/** The newest revision written of each key, and the newest seen; the oldest keys go past what any forge holds open. */
|
/** The newest revision written of each key, and the newest seen; the oldest keys go past what any forge holds open. */
|
||||||
@@ -94,9 +91,6 @@ export class CheckStatuses {
|
|||||||
/** The turn each key's writes wait on, and the asks again still running. */
|
/** The turn each key's writes wait on, and the asks again still running. */
|
||||||
private turns = new Map<string, Promise<unknown>>();
|
private turns = new Map<string, Promise<unknown>>();
|
||||||
private again = new Set<Promise<void>>();
|
private again = new Set<Promise<void>>();
|
||||||
/** Writes at the forge at once, at most WRITES_AT_ONCE: a watch's start hands over every key together (issue 1726). */
|
|
||||||
private writing = 0;
|
|
||||||
private queued: (() => void)[] = [];
|
|
||||||
private forge: CheckForge;
|
private forge: CheckForge;
|
||||||
private say: (line: string) => void;
|
private say: (line: string) => void;
|
||||||
private sleep: (ms: number) => Promise<void>;
|
private sleep: (ms: number) => Promise<void>;
|
||||||
@@ -129,7 +123,7 @@ export class CheckStatuses {
|
|||||||
this.say(`[gitea] ${asked.owner}/${asked.repo}@${asked.sha.slice(0, 12)}: mesh/check at revision ${asked.revision} is older than ${newest}, already written, and is dropped`);
|
this.say(`[gitea] ${asked.owner}/${asked.repo}@${asked.sha.slice(0, 12)}: mesh/check at revision ${asked.revision} is older than ${newest}, already written, and is dropped`);
|
||||||
return "dropped";
|
return "dropped";
|
||||||
}
|
}
|
||||||
await this.atTheForge(() => this.forge.setCommitStatus(asked.owner, asked.repo, asked.sha, asked.status));
|
await this.forge.setCommitStatus(asked.owner, asked.repo, asked.sha, asked.status);
|
||||||
this.remember(this.written, key, asked.revision);
|
this.remember(this.written, key, asked.revision);
|
||||||
return "written";
|
return "written";
|
||||||
});
|
});
|
||||||
@@ -139,32 +133,19 @@ export class CheckStatuses {
|
|||||||
return turn;
|
return turn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the watch hands over: taken at once and written in the background, or when the forge refuses, asked again on a
|
/** What the watch hands over: written, or when the forge refuses, asked again on a bound in the background until it is
|
||||||
* bound until it is written or a newer revision of the key was seen. Never throws: a watch's handler that throws loses
|
* written or a newer revision of the key was seen. Never throws: a watch's handler that throws loses the change. */
|
||||||
* the change. It never waits on the forge (novox/hq issue 1726): the runtime hands a watch's current values one at a
|
|
||||||
* time, waits for each to be taken and gives the whole set 10 s; a forge write per key made a large owed outlast that
|
|
||||||
* bound at every attempt, so the watch never held and no verdict reached mesh/check. Each key's writes still take
|
|
||||||
* turns in revision order (apply). */
|
|
||||||
async take(change: OwedChange): Promise<void> {
|
async take(change: OwedChange): Promise<void> {
|
||||||
const run = this.apply(change).then(() => {}, (err) => this.askAgain(change, err));
|
|
||||||
this.again.add(run);
|
|
||||||
void run.finally(() => this.again.delete(run));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Runs one forge write when fewer than WRITES_AT_ONCE are running, else after one ends. */
|
|
||||||
private async atTheForge<T>(write: () => Promise<T>): Promise<T> {
|
|
||||||
if (this.writing >= WRITES_AT_ONCE) await new Promise<void>((r) => this.queued.push(r));
|
|
||||||
else this.writing++;
|
|
||||||
try {
|
try {
|
||||||
return await write();
|
await this.apply(change);
|
||||||
} finally {
|
} catch (err) {
|
||||||
const next = this.queued.shift();
|
const run = this.askAgain(change, err);
|
||||||
if (next) next();
|
this.again.add(run);
|
||||||
else this.writing--;
|
void run.finally(() => this.again.delete(run));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves when no write or ask again is running. */
|
/** Resolves when no ask again is running. */
|
||||||
async settled(): Promise<void> {
|
async settled(): Promise<void> {
|
||||||
while (this.again.size) await Promise.all([...this.again]);
|
while (this.again.size) await Promise.all([...this.again]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,45 +133,3 @@ test("no tool writes mesh/check: gitea_check_status is gone and gitea_commit_sta
|
|||||||
const index = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
|
const index = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
|
||||||
assert.ok(!/gitea_check_status|checkStatusTool/.test(index), "index.ts serves no gitea_check_status");
|
assert.ok(!/gitea_check_status|checkStatusTool/.test(index), "index.ts serves no gitea_check_status");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the watch's current values are taken without waiting on the forge, so a slow forge never times the watch out (issue 1726)", async () => {
|
|
||||||
// The tool runtime hands a watch's current values one at a time and waits for each to be taken; it gives the whole set
|
|
||||||
// 10 s (mesh-tools StateTimeout) and otherwise ends the watch. On 2026-10-23 the owed held ~200 keys, each written to the
|
|
||||||
// forge before the next was handed over: the set took longer than 10 s, the watch ended at every attempt, and no
|
|
||||||
// verdict reached the forge's mesh/check again.
|
|
||||||
const { CheckStatuses, watchOwed } = await import("../check.ts");
|
|
||||||
const sent: string[] = [];
|
|
||||||
let release: () => void = () => {};
|
|
||||||
const slow = new Promise<void>((r) => { release = r; });
|
|
||||||
const f = { setCommitStatus: async (_o: string, _r: string, s: string, st: any) => { await slow; sent.push(`${s.slice(0, 4)} ${st.state}`); } };
|
|
||||||
const checks = new CheckStatuses(f, () => {}, noSleep);
|
|
||||||
const keys = Array.from({ length: 200 }, (_, i) => key(i.toString(16).padStart(4, "0") + sha.slice(4), "novox/mesh-sdk", i + 1));
|
|
||||||
let handedOver = 0;
|
|
||||||
let attempts = 0;
|
|
||||||
const watch = async (handler: (c: any) => Promise<void>) => {
|
|
||||||
if (++attempts > 1) return; // a second attempt is not what this test waits for: the first must hold
|
|
||||||
// The runtime's bound on the current values: the forge has answered none of them yet.
|
|
||||||
const timedOut = new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 200));
|
|
||||||
const delivered = (async () => { for (const [i, k] of keys.entries()) { await handler(put(k, owed(), i + 1, true)); handedOver++; } return "delivered" as const; })();
|
|
||||||
if (await Promise.race([delivered, timedOut]) === "timeout") throw new Error("the current values of mesh-delivery-checker.owed did not arrive in 10s");
|
|
||||||
};
|
|
||||||
const said: string[] = [];
|
|
||||||
await watchOwed(watch, checks, noSleep, (l) => said.push(l));
|
|
||||||
assert.equal(handedOver, 200, "every current value was taken before the forge answered");
|
|
||||||
assert.ok(!said.some((l) => /could not be bound/.test(l)), said.join(" | "));
|
|
||||||
assert.equal(sent.length, 0, "nothing is written before the forge answers");
|
|
||||||
release();
|
|
||||||
await checks.settled();
|
|
||||||
assert.equal(sent.length, 200, "every owed status is written once the forge answers");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the writes a watch's start hands over go to the forge a few at a time, never all at once (issue 1726)", async () => {
|
|
||||||
const { CheckStatuses, WRITES_AT_ONCE } = await import("../check.ts");
|
|
||||||
let running = 0, most = 0, done = 0;
|
|
||||||
const f = { setCommitStatus: async () => { running++; most = Math.max(most, running); await new Promise((r) => setTimeout(r, 1)); running--; done++; } };
|
|
||||||
const checks = new CheckStatuses(f, () => {}, noSleep);
|
|
||||||
for (let i = 0; i < 50; i++) await checks.take(put(key(i.toString(16).padStart(4, "0") + sha.slice(4), "novox/mesh-sdk", i + 1), owed(), i + 1, true));
|
|
||||||
await checks.settled();
|
|
||||||
assert.equal(done, 50);
|
|
||||||
assert.equal(most, WRITES_AT_ONCE);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,25 +14,16 @@ import (
|
|||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
"git.novox.be/novox/mesh-sdk/go/testbus"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkCommit is the commit the check ask fetches, apart from the delivery ask's, so the fetch order names which ran first.
|
|
||||||
const checkCommit = "89abcdef0123456789abcdef0123456789abcdef"
|
|
||||||
|
|
||||||
// 1887/AC-order, 1887/AC-pull against the real bus: a check ask published before a delivery ask runs after it, and at
|
// 1887/AC-order, 1887/AC-pull against the real bus: a check ask published before a delivery ask runs after it, and at
|
||||||
// capacity 1 the second waits in the stream until the first ends.
|
// capacity 1 the second waits in the stream until the first ends.
|
||||||
//
|
|
||||||
// **A bus of its own.** The integration level runs every directory's tests at once on one bus, and this test names the
|
|
||||||
// build seat's stream and its fixed workers (SEAT_NODE_BUILD_GO and its workers) and deletes the stream at its end, so
|
|
||||||
// anything else on that bus, a puller or a leftover consumer, could hold or take its delivery ask and let the check
|
|
||||||
// ask run first. It starts from testbus (mesh-sdk, novox/hq ADR 0495 clause 5): a bus for this test alone, gone after it.
|
|
||||||
//
|
|
||||||
// **The order is read from the record.** At capacity 1 the puller starts the check the instant the delivery ask's step
|
|
||||||
// ends, well before any poll, so "the check key holds a state" is true on correct behaviour (issue 1887). The test
|
|
||||||
// waits for both asks to end and compares the step records' own Started and Ended times, and the fetch order by commit.
|
|
||||||
func TestTheModuleTakesADeliveryAskBeforeAnEarlierCheck(t *testing.T) {
|
func TestTheModuleTakesADeliveryAskBeforeAnEarlierCheck(t *testing.T) {
|
||||||
nc, err := nats.Connect(testbus.URL(t))
|
url := os.Getenv("MESH_TEST_NATS")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("MESH_TEST_NATS names no bus: the integration level starts one")
|
||||||
|
}
|
||||||
|
nc, err := nats.Connect(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -48,7 +40,6 @@ func TestTheModuleTakesADeliveryAskBeforeAnEarlierCheck(t *testing.T) {
|
|||||||
t.Cleanup(func() { _ = js.DeleteStream(context.Background(), "SEAT_NODE_BUILD_GO") })
|
t.Cleanup(func() { _ = js.DeleteStream(context.Background(), "SEAT_NODE_BUILD_GO") })
|
||||||
check := deliveryAsk()
|
check := deliveryAsk()
|
||||||
check.Kind = delivery.AskCheck
|
check.Kind = delivery.AskCheck
|
||||||
check.Build.Fetch["commit"] = checkCommit
|
|
||||||
first := deliveryAsk()
|
first := deliveryAsk()
|
||||||
for _, a := range []delivery.StepAsk{check, first} {
|
for _, a := range []delivery.StepAsk{check, first} {
|
||||||
raw, _ := delivery.EncodeStepAsk(a)
|
raw, _ := delivery.EncodeStepAsk(a)
|
||||||
@@ -77,36 +68,30 @@ func TestTheModuleTakesADeliveryAskBeforeAnEarlierCheck(t *testing.T) {
|
|||||||
p := m.Puller(steps, &buildstep.JetStreamAsks{JS: js, Language: delivery.AskGo})
|
p := m.Puller(steps, &buildstep.JetStreamAsks{JS: js, Language: delivery.AskGo})
|
||||||
p.PullWait = 200 * time.Millisecond
|
p.PullWait = 200 * time.Millisecond
|
||||||
go p.Pull(ctx)
|
go p.Pull(ctx)
|
||||||
var ran map[string][]delivery.Step
|
for _, a := range []delivery.StepAsk{first, check} {
|
||||||
for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(10 * time.Millisecond) {
|
key, _ := delivery.AskStepKey("ace", a.ID)
|
||||||
ran = map[string][]delivery.Step{}
|
for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(10 * time.Millisecond) {
|
||||||
ended := 0
|
states.mu.Lock()
|
||||||
states.mu.Lock()
|
v := states.steps[key]
|
||||||
for _, a := range []delivery.StepAsk{first, check} {
|
states.mu.Unlock()
|
||||||
key, _ := delivery.AskStepKey("ace", a.ID)
|
|
||||||
v := append([]delivery.Step(nil), states.steps[key]...)
|
|
||||||
ran[a.ID] = v
|
|
||||||
if len(v) > 0 && v[len(v)-1].State != delivery.StepRunning {
|
if len(v) > 0 && v[len(v)-1].State != delivery.StepRunning {
|
||||||
ended++
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("the ask %s (%s) never ended", a.ID, a.Kind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
states.mu.Lock()
|
||||||
|
checkKey, _ := delivery.AskStepKey("ace", check.ID)
|
||||||
|
if a.ID == first.ID && len(states.steps[checkKey]) > 0 {
|
||||||
|
states.mu.Unlock()
|
||||||
|
t.Fatal("the check ask started before the delivery ask ended at capacity 1")
|
||||||
|
}
|
||||||
states.mu.Unlock()
|
states.mu.Unlock()
|
||||||
if ended == 2 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("the asks never both ended: %v", ran)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The check's step may start the moment the delivery's ends, so the order is read from the records' own times.
|
|
||||||
deliveryEnded := ran[first.ID][len(ran[first.ID])-1].Ended
|
|
||||||
checkStarted := ran[check.ID][0].Started
|
|
||||||
if checkStarted.Before(deliveryEnded) {
|
|
||||||
t.Fatalf("the check ask started at %v, before the delivery ask ended at %v at capacity 1", checkStarted, deliveryEnded)
|
|
||||||
}
|
}
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
if len(order) != 2 || order[0] != askCommit || order[1] != checkCommit {
|
if len(order) != 2 {
|
||||||
t.Fatalf("fetched %v, want the delivery ask's commit %s first, then the check's %s", order, askCommit, checkCommit)
|
t.Fatalf("fetched %v", order)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ func TestACheckAsksDigestIsTheToolsAnswerInTheCall(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if l := out.(delivery.CheckAnswer).Levels; len(l) != 1 || l[0].Digest.Map()["x/a"] != "d1" {
|
if l := out.(delivery.CheckAnswer).Levels; len(l) != 1 || !strings.Contains(string(l[0].Digest), `"x/a":"d1"`) {
|
||||||
t.Fatalf("levels = %+v, want the digest's packages", l)
|
t.Fatalf("levels = %+v, want the digest's packages", l)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -28,7 +27,7 @@ func TestAFailedTestKeepsItsFatalMessageAfterItsLogLines(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if want := "3 subscription(s) derive to a subject no emitter owns:"; f.Cause != want && !strings.HasSuffix(f.Cause, want) {
|
if want := "3 subscription(s) derive to a subject no emitter owns:"; f.Cause != want && !strings.HasSuffix(f.Cause, want) {
|
||||||
t.Errorf("1901/AC1: cause = %q, want the t.Fatalf line, not the t.Logf line", f.Cause)
|
t.Errorf("1901/AC2: cause = %q, want the t.Fatalf line, not the t.Logf line", f.Cause)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,74 +38,11 @@ func TestAFailureLongerThanTheBoundKeepsItsLastLines(t *testing.T) {
|
|||||||
lines = append(lines, " x_test.go:1: log line "+strings.Repeat("y", 40))
|
lines = append(lines, " x_test.go:1: log line "+strings.Repeat("y", 40))
|
||||||
}
|
}
|
||||||
lines = append(lines, " x_test.go:9: the real cause", "--- FAIL: TestX (0.00s)")
|
lines = append(lines, " x_test.go:9: the real cause", "--- FAIL: TestX (0.00s)")
|
||||||
out := clip(lines, "")
|
out := clip(lines)
|
||||||
if len(out) > outputBytes+100 {
|
if len(out) > outputBytes+100 {
|
||||||
t.Errorf("1901/AC1: kept %d bytes, bound is %d", len(out), outputBytes)
|
t.Errorf("1901/AC1: kept %d bytes, bound is %d", len(out), outputBytes)
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(out, "the real cause\n--- FAIL: TestX (0.00s)") || !strings.Contains(out, "\n... (") {
|
if !strings.HasSuffix(out, "the real cause\n--- FAIL: TestX (0.00s)") || !strings.HasPrefix(out, "... (") {
|
||||||
t.Errorf("1901/AC1: out = %q...%q", out[:40], out[len(out)-60:])
|
t.Errorf("1901/AC1: out = %q...%q", out[:40], out[len(out)-60:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An older checker, which knows no "cause" field, still reads the new build module's answer, and the output it gets
|
|
||||||
// holds at least what the older build module kept (its first lines, for an output shorter than the bound).
|
|
||||||
func TestAnOlderCheckerReadsTheNewFailureAndGetsAtLeastWhatItKept(t *testing.T) { // 1901/AC3
|
|
||||||
raw, err := os.ReadFile("testdata/captured/go-test-json-log-then-fatal.jsonl")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
res := &TestResult{}
|
|
||||||
rd := newReader(res, ".")
|
|
||||||
rd.read(string(raw))
|
|
||||||
rd.finish()
|
|
||||||
b, err := json.Marshal(res.Failed)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var old []struct {
|
|
||||||
Package string `json:"package"`
|
|
||||||
Test string `json:"test,omitempty"`
|
|
||||||
Output string `json:"output,omitempty"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(b, &old); err != nil || len(old) != 1 {
|
|
||||||
t.Fatalf("1901/AC3: an older checker refused the answer: %v, %d failures", err, len(old))
|
|
||||||
}
|
|
||||||
first := strings.SplitN(old[0].Output, "\n", 2)[0]
|
|
||||||
if first == "" || !strings.Contains(old[0].Output, "judged against") || !strings.Contains(old[0].Output, "3 subscription(s)") {
|
|
||||||
t.Errorf("1901/AC3: older checker's output = %q", old[0].Output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A long failure, whose earlier lines are left out, still starts with a real line for an older checker, which keeps only the
|
|
||||||
// first non-empty line of the output cut to 120 characters, and not with the marker.
|
|
||||||
func TestALongFailureStillStartsWithARealLineForAnOlderChecker(t *testing.T) { // 1901/AC3
|
|
||||||
var lines []string
|
|
||||||
for i := 0; i < 400; i++ {
|
|
||||||
lines = append(lines, " x_test.go:1: log line "+strings.Repeat("y", 40))
|
|
||||||
}
|
|
||||||
lines = append(lines, " x_test.go:9: the real cause", "--- FAIL: TestX (0.00s)")
|
|
||||||
oldFirst := func(out string) string { // the older checker's rule (judge.go firstLine)
|
|
||||||
for _, l := range strings.Split(out, "\n") {
|
|
||||||
if l = strings.TrimSpace(l); l != "" {
|
|
||||||
if len(l) > 120 {
|
|
||||||
l = l[:120]
|
|
||||||
}
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, cause := range []string{"the real cause", ""} {
|
|
||||||
out := clip(lines, cause)
|
|
||||||
got := oldFirst(out)
|
|
||||||
if strings.HasPrefix(got, "... (") || got == "" {
|
|
||||||
t.Errorf("1901/AC3: cause %q: an older checker would read %q", cause, got)
|
|
||||||
}
|
|
||||||
if cause != "" && got != cause {
|
|
||||||
t.Errorf("1901/AC3: older checker reads %q, want the cause", got)
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(out, "--- FAIL: TestX (0.00s)") {
|
|
||||||
t.Errorf("1901/AC3: the last lines are lost: %q", out[len(out)-40:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -207,17 +207,15 @@ func (r *reader) event(ev event) {
|
|||||||
r.times = append(r.times, TestTime{Package: ev.Package, Test: ev.Test, Elapsed: ev.Elapsed})
|
r.times = append(r.times, TestTime{Package: ev.Package, Test: ev.Test, Elapsed: ev.Elapsed})
|
||||||
if ev.Action == "fail" {
|
if ev.Action == "fail" {
|
||||||
r.failedIn[ev.Package] = true
|
r.failedIn[ev.Package] = true
|
||||||
r.res.Failed = append(r.res.Failed, Failure{Package: ev.Package, Test: ev.Test, Output: clip(r.testOut[key], r.causes[key]), Cause: r.causes[key]})
|
r.res.Failed = append(r.res.Failed, Failure{Package: ev.Package, Test: ev.Test, Output: clip(r.testOut[key]), Cause: r.causes[key]})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// clip joins the last lines of a failure's output that fit outputBytes, each cut to outputLineBytes, and says how many
|
// clip joins the last lines of a failure's output that fit outputBytes, each cut to outputLineBytes, and says how many
|
||||||
// earlier lines it left out. **When it leaves lines out, its first line is still a real one** (issue 1901/AC3): the failure's
|
// earlier lines it left out.
|
||||||
// cause, or failing that the first non-empty line of the output, then the marker. An older checker takes only the first
|
func clip(lines []string) string {
|
||||||
// non-empty line of the output, and a marker there would be less than the older pair kept.
|
|
||||||
func clip(lines []string, cause string) string {
|
|
||||||
size, from := 0, len(lines)
|
size, from := 0, len(lines)
|
||||||
for from > 0 {
|
for from > 0 {
|
||||||
n := len(cutLine(lines[from-1])) + 1
|
n := len(cutLine(lines[from-1])) + 1
|
||||||
@@ -229,13 +227,6 @@ func clip(lines []string, cause string) string {
|
|||||||
}
|
}
|
||||||
kept := make([]string, 0, len(lines)-from+1)
|
kept := make([]string, 0, len(lines)-from+1)
|
||||||
if from > 0 {
|
if from > 0 {
|
||||||
head := strings.TrimSpace(cause)
|
|
||||||
for i := 0; head == "" && i < from; i++ {
|
|
||||||
head = strings.TrimSpace(lines[i])
|
|
||||||
}
|
|
||||||
if head != "" {
|
|
||||||
kept = append(kept, cutLine(head))
|
|
||||||
}
|
|
||||||
kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from))
|
kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from))
|
||||||
}
|
}
|
||||||
for _, l := range lines[from:] {
|
for _, l := range lines[from:] {
|
||||||
@@ -265,7 +256,7 @@ func (r *reader) finish() {
|
|||||||
for _, name := range r.order {
|
for _, name := range r.order {
|
||||||
p := r.pkgs[name]
|
p := r.pkgs[name]
|
||||||
if !p.Passed && !r.failedIn[name] {
|
if !p.Passed && !r.failedIn[name] {
|
||||||
r.res.Failed = append(r.res.Failed, Failure{Package: name, Output: clip(r.pkgOut[name], "")})
|
r.res.Failed = append(r.res.Failed, Failure{Package: name, Output: clip(r.pkgOut[name])})
|
||||||
}
|
}
|
||||||
if !p.NoTests && p.Elapsed > r.res.Elapsed {
|
if !p.NoTests && p.Elapsed > r.res.Elapsed {
|
||||||
r.res.Elapsed = p.Elapsed
|
r.res.Elapsed = p.Elapsed
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||||
)
|
)
|
||||||
@@ -128,45 +127,3 @@ func (m *Module) lint(ctx context.Context, run forgekey.Runner, tree, d string,
|
|||||||
}
|
}
|
||||||
return failed, nil
|
return failed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// lintAll lints each directory, up to m.parallel() at once like the level's tests; the failures join in directory order
|
|
||||||
// and the error is the first directory's, so the answer does not depend on which ended first (issue 1930).
|
|
||||||
func (m *Module) lintAll(ctx context.Context, run forgekey.Runner, tree string, dirs []string, env func(d string) []string) ([]Failure, error) {
|
|
||||||
type answer struct {
|
|
||||||
failed []Failure
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
answers := make([]answer, len(dirs))
|
|
||||||
slots := make(chan struct{}, m.parallel())
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
var crash any // a panic of a lint goes on in the caller, as a panic of a test run does
|
|
||||||
var crashMu sync.Mutex
|
|
||||||
for i, d := range dirs {
|
|
||||||
wg.Add(1)
|
|
||||||
slots <- struct{}{}
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
defer func() { <-slots }()
|
|
||||||
defer func() {
|
|
||||||
if p := recover(); p != nil {
|
|
||||||
crashMu.Lock()
|
|
||||||
crash = p
|
|
||||||
crashMu.Unlock()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
answers[i].failed, answers[i].err = m.lint(ctx, run, tree, d, env(d))
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
if crash != nil {
|
|
||||||
panic(crash)
|
|
||||||
}
|
|
||||||
var failed []Failure
|
|
||||||
for _, a := range answers {
|
|
||||||
if a.err != nil {
|
|
||||||
return nil, a.err
|
|
||||||
}
|
|
||||||
failed = append(failed, a.failed...)
|
|
||||||
}
|
|
||||||
return failed, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||||
)
|
)
|
||||||
@@ -92,43 +90,3 @@ func TestAnUnformattedFileFailsTheUnitLevel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue 1930: the lint of many directories runs side by side up to the level's bound, not one after another, and the
|
|
||||||
// failures still join in directory order.
|
|
||||||
func TestTheLintOfManyDirectoriesRunsAtOnceUpToTheBound(t *testing.T) {
|
|
||||||
files := map[string]string{}
|
|
||||||
var dirs []string
|
|
||||||
for _, d := range []string{"a", "b", "c", "d", "e", "f"} {
|
|
||||||
files[d+"/go.mod"] = "module x/" + d + "\n"
|
|
||||||
files[d+"/x.go"] = "package x\n"
|
|
||||||
dirs = append(dirs, d)
|
|
||||||
}
|
|
||||||
tree := writeTree(t, files)
|
|
||||||
var mu sync.Mutex
|
|
||||||
running, most := 0, 0
|
|
||||||
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
|
||||||
mu.Lock()
|
|
||||||
running++
|
|
||||||
most = max(most, running)
|
|
||||||
mu.Unlock()
|
|
||||||
time.Sleep(30 * time.Millisecond)
|
|
||||||
mu.Lock()
|
|
||||||
running--
|
|
||||||
mu.Unlock()
|
|
||||||
if strings.HasSuffix(args[1], "b") || strings.HasSuffix(args[1], "e") {
|
|
||||||
return forgekey.Ran{Status: 1, Stderr: "bad " + filepath.Base(args[1])}
|
|
||||||
}
|
|
||||||
return forgekey.Ran{}
|
|
||||||
}
|
|
||||||
m := &Module{Parallel: 3}
|
|
||||||
failed, err := m.lintAll(context.Background(), run, tree, dirs, func(string) []string { return nil })
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if most < 2 || most > 3 {
|
|
||||||
t.Errorf("%d lint commands ran at once, want 2 or 3 (the bound)", most)
|
|
||||||
}
|
|
||||||
if len(failed) != 2 || failed[0].Package != "b" || failed[1].Package != "e" {
|
|
||||||
t.Errorf("failed = %+v, want b's then e's", failed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 1925/AC1: packagesArg takes the list as the bus's JSON shape ([]any) and as the typed list an ask hands in process
|
|
||||||
// ([]string), and refuses a bad element in either.
|
|
||||||
func TestPackagesArgTakesBothShapes(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in any
|
|
||||||
want []string
|
|
||||||
refused bool
|
|
||||||
}{
|
|
||||||
{"absent", nil, nil, false},
|
|
||||||
{"typed", []string{"x/a", "x/b/..."}, []string{"x/a", "x/b/..."}, false},
|
|
||||||
{"json", []any{"x/a", "x/b/..."}, []string{"x/a", "x/b/..."}, false},
|
|
||||||
{"typed empty", []string{}, []string{}, false},
|
|
||||||
{"typed dotdot", []string{"x/../y"}, nil, true},
|
|
||||||
{"json dotdot", []any{"x/../y"}, nil, true},
|
|
||||||
{"typed bad", []string{"x a"}, nil, true},
|
|
||||||
{"json bad", []any{"x a"}, nil, true},
|
|
||||||
{"json not a string", []any{3}, nil, true},
|
|
||||||
{"a string", "x/a", nil, true},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
got, err := packagesArg(c.in)
|
|
||||||
if c.refused != (err != nil) {
|
|
||||||
t.Errorf("%s: err = %v, refused want %v", c.name, err, c.refused)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !c.refused && !reflect.DeepEqual(got, c.want) {
|
|
||||||
t.Errorf("%s: got %#v, want %#v", c.name, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1925/AC2: a check ask through the module's real build_go_test body gets past the packages argument: the digest names
|
|
||||||
// a package, buildstep.RunAsk hands it in as a typed list, and the step fails only later, on the workspace nobody fetched.
|
|
||||||
func TestACheckAskReachesTheTestStepPastItsPackages(t *testing.T) {
|
|
||||||
m := &Module{Node: "ace", Work: t.TempDir()}
|
|
||||||
var calls []string
|
|
||||||
steps := fakeSteps(&calls)
|
|
||||||
for i := range steps {
|
|
||||||
switch steps[i].Name {
|
|
||||||
case "build_go_digest":
|
|
||||||
steps[i].Run = func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
return map[string]any{"level": "unit", "packages": map[string]string{"x/a": "d1"}}, nil
|
|
||||||
}
|
|
||||||
case "build_go_test":
|
|
||||||
for _, real := range m.StepTools() {
|
|
||||||
if real.Name == "build_go_test" {
|
|
||||||
steps[i].Run = real.Run
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a := delivery.StepAsk{ID: buildstep.NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "mesh-delivery-checker",
|
|
||||||
At: time.Now(), Check: &delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": askCommit}, Levels: []string{delivery.LevelUnit}}}
|
|
||||||
out, err := buildstep.RunAsk(context.Background(), m.askSteps(steps), a)
|
|
||||||
got := ""
|
|
||||||
if err != nil {
|
|
||||||
got = err.Error()
|
|
||||||
}
|
|
||||||
if strings.Contains(got, "packages is a list") {
|
|
||||||
t.Fatalf("the test step refused the ask's packages: %s", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "no workspace") {
|
|
||||||
t.Fatalf("want the step to fail on the unfetched workspace, past its packages; err=%v out=%+v", err, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -127,25 +127,17 @@ func (m *Module) besideEnv(workspace string) []string {
|
|||||||
return []string{"MESH_CHECK_BESIDE=" + dir}
|
return []string{"MESH_CHECK_BESIDE=" + dir}
|
||||||
}
|
}
|
||||||
|
|
||||||
// packagesArg reads the packages argument: a list of package patterns, as []any when it came over the bus as JSON and
|
|
||||||
// as []string when an ask handed it in process (issue 1925).
|
|
||||||
func packagesArg(v any) ([]string, error) {
|
func packagesArg(v any) ([]string, error) {
|
||||||
var list []string
|
if v == nil {
|
||||||
switch t := v.(type) {
|
|
||||||
case nil:
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
case []string:
|
}
|
||||||
list = t
|
list, ok := v.([]any)
|
||||||
case []any:
|
if !ok {
|
||||||
for _, e := range t {
|
|
||||||
s, _ := e.(string)
|
|
||||||
list = append(list, s)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("packages is a list of package patterns")
|
return nil, fmt.Errorf("packages is a list of package patterns")
|
||||||
}
|
}
|
||||||
out := []string{}
|
out := []string{}
|
||||||
for _, s := range list {
|
for _, e := range list {
|
||||||
|
s, _ := e.(string)
|
||||||
if !packageOK.MatchString(s) || strings.Contains(strings.TrimSuffix(s, "/..."), "..") {
|
if !packageOK.MatchString(s) || strings.Contains(strings.TrimSuffix(s, "/..."), "..") {
|
||||||
return nil, fmt.Errorf("%q is not a package pattern", s)
|
return nil, fmt.Errorf("%q is not a package pattern", s)
|
||||||
}
|
}
|
||||||
@@ -209,11 +201,13 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
|
|||||||
// gofmt and go vet of each directory the level runs come before its tests (ADR 0529 §1; issue 1887 D7-go): a
|
// gofmt and go vet of each directory the level runs come before its tests (ADR 0529 §1; issue 1887 D7-go): a
|
||||||
// directory that fails them fails the level at once, and their time is the result's Lint.
|
// directory that fails them fails the level at once, and their time is the result's Lint.
|
||||||
linted := m.clock()
|
linted := m.clock()
|
||||||
failed, err := m.lintAll(ctx, run, tree, todo, func(d string) []string {
|
var failed []Failure
|
||||||
return append(m.goEnvFor(filepath.Join(tree, d)), o.env...)
|
for _, d := range todo {
|
||||||
})
|
f, err := m.lint(ctx, run, tree, d, append(m.goEnvFor(filepath.Join(tree, d)), o.env...))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
}
|
||||||
|
failed = append(failed, f...)
|
||||||
}
|
}
|
||||||
res.Lint = m.clock().Sub(linted).Seconds()
|
res.Lint = m.clock().Sub(linted).Seconds()
|
||||||
if len(failed) > 0 {
|
if len(failed) > 0 {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module meshbuildgo
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468
|
||||||
github.com/distribution/distribution/v3 v3.1.2
|
github.com/distribution/distribution/v3 v3.1.2
|
||||||
github.com/nats-io/nats-server/v2 v2.11.17
|
github.com/nats-io/nats-server/v2 v2.11.17
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93 h1:FAiFY2KBKEoZSH49OZTt7/zRBIQxyl5g/KhYrpggbN4=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module meshbuildimage
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468
|
||||||
github.com/nats-io/nats-server/v2 v2.11.17
|
github.com/nats-io/nats-server/v2 v2.11.17
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93 h1:FAiFY2KBKEoZSH49OZTt7/zRBIQxyl5g/KhYrpggbN4=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module meshbuildnode
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468
|
||||||
github.com/distribution/distribution/v3 v3.1.2
|
github.com/distribution/distribution/v3 v3.1.2
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93 h1:FAiFY2KBKEoZSH49OZTt7/zRBIQxyl5g/KhYrpggbN4=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
|
|||||||
@@ -514,11 +514,6 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
|
|||||||
announced.Title = "Fix the apostrophe (issue 975)"
|
announced.Title = "Fix the apostrophe (issue 975)"
|
||||||
announced.Body = "The description, with no review line yet."
|
announced.Body = "The description, with no review line yet."
|
||||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
|
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
|
||||||
rt.until("the pull request's key to hold its head", eventWithin, func() bool {
|
|
||||||
e, err := b.kvs["pulls"].Get(ctx, "novox.app.7")
|
|
||||||
var k PullKey
|
|
||||||
return err == nil && json.Unmarshal(e.Value(), &k) == nil && k.Head == head && k.Trunk == "a1a1a1a1a1a1"
|
|
||||||
})
|
|
||||||
b.checkPasses(t, rt, "fix/x", "a1a1a1a1a1a1", judgedPull{"novox/app", 7, head})
|
b.checkPasses(t, rt, "fix/x", "a1a1a1a1a1a1", judgedPull{"novox/app", 7, head})
|
||||||
|
|
||||||
// No review names the head, so nothing is asked (ADR 0496, issue 1803): the delivery waits for a review.
|
// No review names the head, so nothing is asked (ADR 0496, issue 1803): the delivery waits for a review.
|
||||||
|
|||||||
@@ -51,38 +51,7 @@ func (h *Holder) KeepPull(p PullEvent) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
h.Logf("[mesh-delivery] the key of %s/%s#%d could not be written: %v", p.Owner, p.Repo, p.Number, err)
|
h.Logf("[mesh-delivery] the key of %s/%s#%d could not be written: %v", p.Owner, p.Repo, p.Number, err)
|
||||||
h.again("a pull request's key that could not be written")
|
h.again("a pull request's key that could not be written")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// The head's mesh/check may have been heard before this key held the head: the two events are taken on goroutines of
|
|
||||||
// their own, and KeepCheckVerdict then found no head to write a verdict to. The verdict the delivery holds for this head
|
|
||||||
// and trunk is written now; read after the write above landed, it cannot miss one taken before that write.
|
|
||||||
verdict := h.checkVerdictOf(p.repository(), p.HeadSHA, p.BaseSHA)
|
|
||||||
if verdict == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = h.Pulls.Change(pullKeyName(p.Owner, p.Repo, p.Number), func(k PullKey) (PullKey, bool) {
|
|
||||||
if !sameCommit(k.Head, p.HeadSHA) || !sameCommit(k.Trunk, p.BaseSHA) || k.Verdict != "" {
|
|
||||||
return k, false
|
|
||||||
}
|
|
||||||
k.Verdict = verdict
|
|
||||||
return k, true
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
h.Logf("[mesh-delivery] the verdict in the key of %s/%s#%d could not be written: %v", p.Owner, p.Repo, p.Number, err)
|
|
||||||
h.again("a pull request's key that could not be written")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkVerdictOf is the verdict of the mesh/check the delivery of a head holds, when it judged this head on this trunk
|
|
||||||
// commit; empty otherwise. Outside the lock.
|
|
||||||
func (h *Holder) checkVerdictOf(repository, head, trunk string) string {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
d := h.deliveries[IDOf(strings.ToLower(repository), head)]
|
|
||||||
if d == nil || d.Check == nil || d.Check.Status != ctxCheck || !sameCommit(d.Check.Head, head) || !sameCommit(d.Check.Trunk, trunk) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return d.Check.Gate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeepCheckVerdict writes the verdict of a head's mesh/check into the key of its pull request, when the key holds that head
|
// KeepCheckVerdict writes the verdict of a head's mesh/check into the key of its pull request, when the key holds that head
|
||||||
|
|||||||
@@ -145,21 +145,3 @@ func TestTheSameMergeHeardAgainAfterALaterMergeMovesNoTrunkBack(t *testing.T) {
|
|||||||
t.Fatalf("the trunk moved from %q to %q on a merge heard again; wanted it to stay m9m9m9m9m9m9", before.TrunkNow, after.TrunkNow)
|
t.Fatalf("the trunk moved from %q to %q on a merge heard again; wanted it to stay m9m9m9m9m9m9", before.TrunkNow, after.TrunkNow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The forge's mesh/check event handled wholly between the announcement's PullUpdated and its KeepPull (the two events are
|
|
||||||
// taken on goroutines of their own): the verdict must still reach the pull request's key.
|
|
||||||
func TestAVerdictHeardBeforeTheKeyIsWrittenReachesTheKey(t *testing.T) {
|
|
||||||
w := newWorld(t)
|
|
||||||
pulls := newFakePulls()
|
|
||||||
w.h.Pulls = pulls
|
|
||||||
p := announcedAt(readyHead, "a1a1a1a1a1a1")
|
|
||||||
w.h.PullUpdated(p)
|
|
||||||
s := ForgeStatusEvent{Owner: "novox", Repo: "app", SHA: readyHead, Context: ctxCheck, State: "success",
|
|
||||||
Description: "pass " + readyHead[:12] + " on a1a1a1a1a1a1 | unit 3 run"}
|
|
||||||
w.h.CommitStatus(s)
|
|
||||||
w.h.KeepCheckVerdict(s)
|
|
||||||
w.h.KeepPull(p)
|
|
||||||
if got := pulls.get(appKey); got.Head != readyHead || got.Verdict != "pass" {
|
|
||||||
t.Fatalf("the key holds %+v, want the head with the verdict pass", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -90,9 +90,7 @@ func TestAcceptancePublishedMailBecomesAnEntryNeverKeepingItsBody(t *testing.T)
|
|||||||
}
|
}
|
||||||
r.stand.Judge = judgeEveryMailAs("neither", "newsletters", "", "")
|
r.stand.Judge = judgeEveryMailAs("neither", "newsletters", "", "")
|
||||||
r.stand.MessengerCheck = func(map[string]any) (any, error) { return map[string]any{"allowed": true}, nil }
|
r.stand.MessengerCheck = func(map[string]any) (any, error) { return map[string]any{"allowed": true}, nil }
|
||||||
r.stand.Tell = func(map[string]any) (any, error) {
|
r.stand.Tell = func(map[string]any) (any, error) { return map[string]any{"sent_on": ""}, nil }
|
||||||
return map[string]any{"sent_on": nil, "dropped": []string{"telegram"}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "Alice", "address": "alice@example.org"}}, "hi", time.Now())
|
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "Alice", "address": "alice@example.org"}}, "hi", time.Now())
|
||||||
|
|
||||||
@@ -162,7 +160,7 @@ func TestAcceptanceOneMatteringMailSendsExactlyOneTellNamingIt(t *testing.T) {
|
|||||||
var tells recorded[map[string]any]
|
var tells recorded[map[string]any]
|
||||||
r.stand.Tell = func(args map[string]any) (any, error) {
|
r.stand.Tell = func(args map[string]any) (any, error) {
|
||||||
tells.add(args)
|
tells.add(args)
|
||||||
return map[string]any{"sent_on": []string{"telegram"}, "dropped": []string{}}, nil
|
return map[string]any{"sent_on": time.Now().UTC().Format(time.RFC3339)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "Alice", "address": "alice@example.org"}}, "invoice", time.Now())
|
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "Alice", "address": "alice@example.org"}}, "invoice", time.Now())
|
||||||
@@ -210,7 +208,7 @@ func TestAcceptanceSenderNameWithAHostNameGivesTheSafeForm(t *testing.T) {
|
|||||||
var tells recorded[map[string]any]
|
var tells recorded[map[string]any]
|
||||||
r.stand.Tell = func(args map[string]any) (any, error) {
|
r.stand.Tell = func(args map[string]any) (any, error) {
|
||||||
tells.add(args)
|
tells.add(args)
|
||||||
return map[string]any{"sent_on": []string{"telegram"}, "dropped": []string{}}, nil
|
return map[string]any{"sent_on": time.Now().UTC().Format(time.RFC3339)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "mail.evil.example.com", "address": "x@example.org"}}, "hi", time.Now())
|
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "mail.evil.example.com", "address": "x@example.org"}}, "hi", time.Now())
|
||||||
@@ -235,9 +233,9 @@ func TestAcceptanceDroppedTellLeavesMailUntoldAndCountedNextTime(t *testing.T) {
|
|||||||
var tellCalls count
|
var tellCalls count
|
||||||
r.stand.Tell = func(map[string]any) (any, error) {
|
r.stand.Tell = func(map[string]any) (any, error) {
|
||||||
if tellCalls.inc() == 1 {
|
if tellCalls.inc() == 1 {
|
||||||
return map[string]any{"sent_on": nil, "dropped": []string{"telegram"}}, nil // dropped: the operator channel's own cap
|
return map[string]any{"sent_on": ""}, nil // dropped: the operator channel's own cap
|
||||||
}
|
}
|
||||||
return map[string]any{"sent_on": []string{"telegram"}, "dropped": []string{}}, nil
|
return map[string]any{"sent_on": time.Now().UTC().Format(time.RFC3339)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "Alice", "address": "alice@example.org"}}, "hi", time.Now())
|
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "Alice", "address": "alice@example.org"}}, "hi", time.Now())
|
||||||
@@ -271,38 +269,3 @@ func TestAcceptanceDroppedTellLeavesMailUntoldAndCountedNextTime(t *testing.T) {
|
|||||||
})
|
})
|
||||||
assertNeverSendsMovesFlagsOrDeletes(t, r)
|
assertNeverSendsMovesFlagsOrDeletes(t, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1926/AC4: a second batch over an unchanged set of mails that matter (its own mail does not matter) sends no
|
|
||||||
// second signal — the first, answered as the real operator-channel answers it (sent_on a list), marked the set
|
|
||||||
// told.
|
|
||||||
func TestAcceptanceRepeatedBatchesOverAnUnchangedSetSendOneSignal(t *testing.T) {
|
|
||||||
r := startReader(t)
|
|
||||||
r.stand.MailAccounts = oneAccountAnswer("bob")
|
|
||||||
r.stand.MailRead = func(map[string]any) (any, error) { return map[string]any{"text": "hi"}, nil }
|
|
||||||
var judged count
|
|
||||||
matters := judgeEveryMailAs("matters", "", "", "")
|
|
||||||
neither := judgeEveryMailAs("neither", "", "", "")
|
|
||||||
r.stand.Judge = func(args map[string]any) (any, error) {
|
|
||||||
if judged.inc() == 1 {
|
|
||||||
return matters(args)
|
|
||||||
}
|
|
||||||
return neither(args)
|
|
||||||
}
|
|
||||||
r.stand.MessengerCheck = func(map[string]any) (any, error) { return map[string]any{"allowed": true}, nil }
|
|
||||||
var tells count
|
|
||||||
r.stand.Tell = func(map[string]any) (any, error) {
|
|
||||||
tells.inc()
|
|
||||||
return map[string]any{"sent_on": []string{"telegram"}, "dropped": []string{}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "Alice", "address": "alice@example.org"}}, "hi", time.Now())
|
|
||||||
waitFor(t, 30*time.Second, "the first tell", func() bool { return tells.get() >= 1 })
|
|
||||||
publishAdded(t, r.nc, "bob", "INBOX", 2, []map[string]any{{"name": "Bob", "address": "bob2@example.org"}}, "second", time.Now())
|
|
||||||
publishAdded(t, r.nc, "bob", "INBOX", 3, []map[string]any{{"name": "Carol", "address": "carol@example.org"}}, "third", time.Now())
|
|
||||||
waitFor(t, BatchGap+60*time.Second, "later batches judged", func() bool { return judged.get() >= 2 })
|
|
||||||
time.Sleep(2 * BatchGap) // a stray repeat of the signal would come after a batch
|
|
||||||
if got := tells.get(); got != 1 {
|
|
||||||
t.Fatalf("1926/AC4: tells = %d, want exactly 1 for an unchanged set", got)
|
|
||||||
}
|
|
||||||
assertNeverSendsMovesFlagsOrDeletes(t, r)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ func RunBatches(ctx context.Context, store Store, readText ReadTextFunc, judge J
|
|||||||
if err := RunOneBatch(ctx, store, readText, judge, waiting, now, logf); err != nil {
|
if err := RunOneBatch(ctx, store, readText, judge, waiting, now, logf); err != nil {
|
||||||
logf("[mesh-mail-reader] a batch failed: %v", err)
|
logf("[mesh-mail-reader] a batch failed: %v", err)
|
||||||
}
|
}
|
||||||
if err := SendSignal(ctx, store, check, tell, now); err != nil {
|
if err := SendSignal(ctx, store, check, tell); err != nil {
|
||||||
logf("[mesh-mail-reader] the mail signal could not be sent: %v", err)
|
logf("[mesh-mail-reader] the mail signal could not be sent: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ func TestAcceptanceRefusedJudgementIsUnhealthyThenCatchesUpInPublishedOrder(t *t
|
|||||||
var tellCalls count
|
var tellCalls count
|
||||||
r.stand.Tell = func(map[string]any) (any, error) {
|
r.stand.Tell = func(map[string]any) (any, error) {
|
||||||
if tellCalls.inc() == 1 {
|
if tellCalls.inc() == 1 {
|
||||||
return map[string]any{"sent_on": nil, "dropped": []string{"telegram"}}, nil // dropped: the operator channel's own cap (1522/AC2)
|
return map[string]any{"sent_on": ""}, nil // dropped: the operator channel's own cap (1522/AC2)
|
||||||
}
|
}
|
||||||
return map[string]any{"sent_on": []string{"telegram"}, "dropped": []string{}}, nil
|
return map[string]any{"sent_on": time.Now().UTC().Format(time.RFC3339)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MessengerCheck is one seat:messenger.messenger_check ask (ADR 0461 §4): whether text is safe to say on an
|
// MessengerCheck is one seat:messenger.messenger_check ask (ADR 0461 §4): whether text is safe to say on an
|
||||||
@@ -46,75 +44,15 @@ func NewTell(ask Ask) Tell {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
var out struct {
|
var out struct {
|
||||||
SentOn json.RawMessage `json:"sent_on"`
|
SentOn string `json:"sent_on"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
return "", fmt.Errorf("operator-channel.tell's answer could not be read: %.200s", raw)
|
return "", fmt.Errorf("operator-channel.tell's answer could not be read: %.200s", raw)
|
||||||
}
|
}
|
||||||
sentOn, err := readSentOn(out.SentOn)
|
return out.SentOn, nil
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("operator-channel.tell's answer could not be read: %.200s", raw)
|
|
||||||
}
|
|
||||||
return sentOn, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// readSentOn reads tell's sent_on: messenger answers the list of channels that said it (null or empty when
|
|
||||||
// every channel dropped it), never one string (issue 1926: reading it as a string failed every real answer, so
|
|
||||||
// no mail was ever marked told and the same signal went out after every batch). A plain string is still
|
|
||||||
// accepted. The channels are joined with a comma; empty means nothing said it.
|
|
||||||
func readSentOn(raw json.RawMessage) (string, error) {
|
|
||||||
if len(raw) == 0 || string(raw) == "null" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
var list []string
|
|
||||||
if err := json.Unmarshal(raw, &list); err == nil {
|
|
||||||
return strings.Join(list, ","), nil
|
|
||||||
}
|
|
||||||
var one string
|
|
||||||
if err := json.Unmarshal(raw, &one); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return one, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// signalEntries is what the signal counts out of the entries marked matters and not yet told (issue 1926):
|
|
||||||
// a mail whose due date lies before today is left out (AC2: it no longer matters, the mail page still holds
|
|
||||||
// it); the same mail seen twice (one message id in two folders, issue 1849) and the same notice in two
|
|
||||||
// languages (the same sender address, mail day and due date) count once (AC3). The entries left out stay
|
|
||||||
// as they were; the ones that count are returned in the order given.
|
|
||||||
func signalEntries(entries []Entry, now time.Time) []Entry {
|
|
||||||
today := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC)
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var out []Entry
|
|
||||||
for _, e := range entries {
|
|
||||||
if !e.DueDate.IsZero() && e.DueDate.UTC().Before(today) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var keys []string
|
|
||||||
if e.MessageID != "" {
|
|
||||||
keys = append(keys, "id\x00"+e.Account+"\x00"+e.MessageID)
|
|
||||||
}
|
|
||||||
if !e.DueDate.IsZero() && e.SenderAddress != "" {
|
|
||||||
keys = append(keys, "notice\x00"+e.Account+"\x00"+strings.ToLower(e.SenderAddress)+"\x00"+
|
|
||||||
e.Date.UTC().Format("2006-01-02")+"\x00"+e.DueDate.UTC().Format("2006-01-02"))
|
|
||||||
}
|
|
||||||
dup := false
|
|
||||||
for _, k := range keys {
|
|
||||||
if seen[k] {
|
|
||||||
dup = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, k := range keys {
|
|
||||||
seen[k] = true
|
|
||||||
}
|
|
||||||
if !dup {
|
|
||||||
out = append(out, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendSignal is 1522: every entry marked matters and not yet told — including one carried over from an
|
// SendSignal is 1522: every entry marked matters and not yet told — including one carried over from an
|
||||||
// earlier batch that went untold (1429/AC4) — is gathered; its words are built (signal.go) and checked word
|
// earlier batch that went untold (1429/AC4) — is gathered; its words are built (signal.go) and checked word
|
||||||
// by word with check; any one refused falls back to the safe form (1522/AC1). Nothing is sent, and nothing
|
// by word with check; any one refused falls back to the safe form (1522/AC1). Nothing is sent, and nothing
|
||||||
@@ -123,7 +61,7 @@ func signalEntries(entries []Entry, now time.Time) []Entry {
|
|||||||
// own last outcome. The tell sent, every one of those entries is marked told only once it answers sent_on
|
// own last outcome. The tell sent, every one of those entries is marked told only once it answers sent_on
|
||||||
// (1522/AC2); a tell that answers sent_on empty leaves them exactly as they were, for the next signal to
|
// (1522/AC2); a tell that answers sent_on empty leaves them exactly as they were, for the next signal to
|
||||||
// count (1522/AC2, AC3).
|
// count (1522/AC2, AC3).
|
||||||
func SendSignal(ctx context.Context, store Store, check MessengerCheck, tell Tell, now func() time.Time) error {
|
func SendSignal(ctx context.Context, store Store, check MessengerCheck, tell Tell) error {
|
||||||
health, err := store.ReaderHealth(ctx)
|
health, err := store.ReaderHealth(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -135,23 +73,17 @@ func SendSignal(ctx context.Context, store Store, check MessengerCheck, tell Tel
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var untold []Entry
|
var toTell []Entry
|
||||||
|
var mails []SignalMail
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.Mark == MarkMatters && !e.Told {
|
if e.Mark == MarkMatters && !e.Told {
|
||||||
untold = append(untold, e)
|
toTell = append(toTell, e)
|
||||||
|
mails = append(mails, SignalMail{SenderName: e.SenderName, Summary: e.Summary, Date: e.Date, DueDate: e.DueDate})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// What counts (signalEntries) is what is said; every untold entry is marked told once it is said, so an
|
|
||||||
// unchanged set is never said twice (issue 1926/AC1) and one that only holds past-due or duplicate mails
|
|
||||||
// says nothing.
|
|
||||||
toTell := signalEntries(untold, now())
|
|
||||||
if len(toTell) == 0 {
|
if len(toTell) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var mails []SignalMail
|
|
||||||
for _, e := range toTell {
|
|
||||||
mails = append(mails, SignalMail{SenderName: e.SenderName, Summary: e.Summary, Date: e.Date, DueDate: e.DueDate})
|
|
||||||
}
|
|
||||||
|
|
||||||
words := BuildSignal(mails)
|
words := BuildSignal(mails)
|
||||||
allowed := true
|
allowed := true
|
||||||
@@ -176,7 +108,7 @@ func SendSignal(ctx context.Context, store Store, check MessengerCheck, tell Tel
|
|||||||
if sentOn == "" {
|
if sentOn == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, e := range untold {
|
for _, e := range toTell {
|
||||||
e.Told = true
|
e.Told = true
|
||||||
if err := store.UpdateEntry(ctx, e); err != nil {
|
if err := store.UpdateEntry(ctx, e); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -39,7 +38,7 @@ func TestSendSignalChecksWordsThenTellsWithSubjectMail(t *testing.T) {
|
|||||||
check, asked := recordingCheck(true)
|
check, asked := recordingCheck(true)
|
||||||
tell, headlines, subjects := recordingTell("2026-10-21T10:00:00Z")
|
tell, headlines, subjects := recordingTell("2026-10-21T10:00:00Z")
|
||||||
|
|
||||||
if err := SendSignal(ctx, store, check, tell, time.Now); err != nil {
|
if err := SendSignal(ctx, store, check, tell); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(*asked) == 0 || (*asked)[0] != "1 mail matters" {
|
if len(*asked) == 0 || (*asked)[0] != "1 mail matters" {
|
||||||
@@ -65,7 +64,7 @@ func TestSendSignalFallsBackToTheSafeFormWhenRefused(t *testing.T) {
|
|||||||
check, _ := recordingCheck(false)
|
check, _ := recordingCheck(false)
|
||||||
tell, headlines, _ := recordingTell("sent")
|
tell, headlines, _ := recordingTell("sent")
|
||||||
|
|
||||||
if err := SendSignal(ctx, store, check, tell, time.Now); err != nil {
|
if err := SendSignal(ctx, store, check, tell); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(*headlines) != 1 {
|
if len(*headlines) != 1 {
|
||||||
@@ -82,7 +81,7 @@ func TestSendSignalSentOnEmptyLeavesMailsUntold(t *testing.T) {
|
|||||||
check, _ := recordingCheck(true)
|
check, _ := recordingCheck(true)
|
||||||
tellDropped, calls, _ := recordingTell("") // every channel dropped it
|
tellDropped, calls, _ := recordingTell("") // every channel dropped it
|
||||||
|
|
||||||
if err := SendSignal(ctx, store, check, tellDropped, time.Now); err != nil {
|
if err := SendSignal(ctx, store, check, tellDropped); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
entry, _, _ := store.Entry(ctx, id)
|
entry, _, _ := store.Entry(ctx, id)
|
||||||
@@ -95,7 +94,7 @@ func TestSendSignalSentOnEmptyLeavesMailsUntold(t *testing.T) {
|
|||||||
|
|
||||||
// The next signal counts the still-untold mail (1429/AC4): SendSignal asks about it again.
|
// The next signal counts the still-untold mail (1429/AC4): SendSignal asks about it again.
|
||||||
tellSent, headlines, _ := recordingTell("sent-now")
|
tellSent, headlines, _ := recordingTell("sent-now")
|
||||||
if err := SendSignal(ctx, store, check, tellSent, time.Now); err != nil {
|
if err := SendSignal(ctx, store, check, tellSent); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(*headlines) != 1 {
|
if len(*headlines) != 1 {
|
||||||
@@ -115,7 +114,7 @@ func TestSendSignalSendsNothingForScamWarningsAlone(t *testing.T) {
|
|||||||
check, asked := recordingCheck(true)
|
check, asked := recordingCheck(true)
|
||||||
tell, headlines, _ := recordingTell("sent")
|
tell, headlines, _ := recordingTell("sent")
|
||||||
|
|
||||||
if err := SendSignal(ctx, store, check, tell, time.Now); err != nil {
|
if err := SendSignal(ctx, store, check, tell); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(*asked) != 0 || len(*headlines) != 0 {
|
if len(*asked) != 0 || len(*headlines) != 0 {
|
||||||
@@ -133,7 +132,7 @@ func TestSendSignalSendsNothingWhileUnhealthy(t *testing.T) {
|
|||||||
check, asked := recordingCheck(true)
|
check, asked := recordingCheck(true)
|
||||||
tell, headlines, _ := recordingTell("sent")
|
tell, headlines, _ := recordingTell("sent")
|
||||||
|
|
||||||
if err := SendSignal(ctx, store, check, tell, time.Now); err != nil {
|
if err := SendSignal(ctx, store, check, tell); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(*asked) != 0 || len(*headlines) != 0 {
|
if len(*asked) != 0 || len(*headlines) != 0 {
|
||||||
@@ -142,7 +141,7 @@ func TestSendSignalSendsNothingWhileUnhealthy(t *testing.T) {
|
|||||||
|
|
||||||
// After recovery, the first signal counts the mail that went untold while unhealthy.
|
// After recovery, the first signal counts the mail that went untold while unhealthy.
|
||||||
store.SetReaderHealth(ctx, ReaderHealth{Healthy: true})
|
store.SetReaderHealth(ctx, ReaderHealth{Healthy: true})
|
||||||
if err := SendSignal(ctx, store, check, tell, time.Now); err != nil {
|
if err := SendSignal(ctx, store, check, tell); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(*headlines) != 1 {
|
if len(*headlines) != 1 {
|
||||||
@@ -167,120 +166,3 @@ func TestTheManifestInvokesTellAndMessengerCheck(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var signalNow = func() time.Time { return time.Date(2026, 10, 23, 21, 0, 0, 0, time.UTC) }
|
|
||||||
|
|
||||||
// 1926/AC1: the real operator-channel.tell answers sent_on as the list of channels that said it (messenger's
|
|
||||||
// own shape); read as a string it failed, nothing was marked told and every batch sent the same signal again.
|
|
||||||
func TestNewTellReadsSentOnAsMessengerAnswersIt(t *testing.T) {
|
|
||||||
for name, answer := range map[string]string{
|
|
||||||
"a list": `{"key":"tell.1","sent_on":["telegram"],"dropped":[]}`,
|
|
||||||
"a string": `{"sent_on":"telegram"}`,
|
|
||||||
} {
|
|
||||||
tell := NewTell(func(string, map[string]any) (json.RawMessage, error) { return json.RawMessage(answer), nil })
|
|
||||||
sentOn, err := tell("h", nil, "mail")
|
|
||||||
if err != nil || sentOn == "" {
|
|
||||||
t.Fatalf("1926/AC1: %s: sentOn = %q, err = %v, want a channel", name, sentOn, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for name, answer := range map[string]string{
|
|
||||||
"null": `{"sent_on":null,"dropped":["telegram"]}`,
|
|
||||||
"empty list": `{"sent_on":[],"dropped":["telegram"]}`,
|
|
||||||
"absent": `{"dropped":["telegram"]}`,
|
|
||||||
} {
|
|
||||||
tell := NewTell(func(string, map[string]any) (json.RawMessage, error) { return json.RawMessage(answer), nil })
|
|
||||||
sentOn, err := tell("h", nil, "mail")
|
|
||||||
if err != nil || sentOn != "" {
|
|
||||||
t.Fatalf("1926/AC1: %s: sentOn = %q, err = %v, want empty and no error", name, sentOn, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1926/AC1: a set already told is not sent again; a mail added to it is sent alone.
|
|
||||||
func TestSendSignalSendsAnUnchangedSetOnce(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
store := newMemStore()
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
store.AddEntry(ctx, mattersEntry("Alice", "pay the bill"))
|
|
||||||
}
|
|
||||||
check, _ := recordingCheck(true)
|
|
||||||
tell, headlines, _ := recordingTell("telegram")
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
if err := SendSignal(ctx, store, check, tell, signalNow); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(*headlines) != 1 || (*headlines)[0] != "3 mails matter" {
|
|
||||||
t.Fatalf("1926/AC1: tell headlines = %v, want one \"3 mails matter\"", *headlines)
|
|
||||||
}
|
|
||||||
store.AddEntry(ctx, mattersEntry("Bob", "new"))
|
|
||||||
if err := SendSignal(ctx, store, check, tell, signalNow); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(*headlines) != 2 || (*headlines)[1] != "1 mail matters" {
|
|
||||||
t.Fatalf("1926/AC1: tell headlines = %v, want the changed set sent again", *headlines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1926/AC2: a mail whose due date has passed is not counted and does not lead; one due today still leads.
|
|
||||||
func TestSendSignalLeavesPastDueMailsOut(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
store := newMemStore()
|
|
||||||
past := mattersEntry("bpost", "parcel notice")
|
|
||||||
past.DueDate = time.Date(2026, 10, 22, 0, 0, 0, 0, time.UTC)
|
|
||||||
today := mattersEntry("Alice", "pay today")
|
|
||||||
today.DueDate = time.Date(2026, 10, 23, 0, 0, 0, 0, time.UTC)
|
|
||||||
store.AddEntry(ctx, past)
|
|
||||||
store.AddEntry(ctx, today)
|
|
||||||
check, _ := recordingCheck(true)
|
|
||||||
var headlines []string
|
|
||||||
var lines [][]string
|
|
||||||
tell := func(h string, l []string, _ string) (string, error) {
|
|
||||||
headlines, lines = append(headlines, h), append(lines, l)
|
|
||||||
return "telegram", nil
|
|
||||||
}
|
|
||||||
if err := SendSignal(ctx, store, check, tell, signalNow); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(headlines) != 1 || headlines[0] != "1 mail matters" || len(lines[0]) != 1 || lines[0][0] != "Alice — pay today — due 2026-10-23" {
|
|
||||||
t.Fatalf("1926/AC2: told %v %v, want only Alice's mail due today", headlines, lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A set holding only past-due mails says nothing.
|
|
||||||
store2 := newMemStore()
|
|
||||||
store2.AddEntry(ctx, past)
|
|
||||||
headlines = nil
|
|
||||||
if err := SendSignal(ctx, store2, check, tell, signalNow); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(headlines) != 0 {
|
|
||||||
t.Fatalf("1926/AC2: told %v for past-due mails alone, want nothing", headlines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1926/AC3: the same notice in two languages (same sender, same day, same due date) counts once, and so does
|
|
||||||
// the same message id in two folders (issue 1849); two different mails of one sender stay two.
|
|
||||||
func TestSendSignalCountsADuplicateNoticeOnce(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
store := newMemStore()
|
|
||||||
due := time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC)
|
|
||||||
day := time.Date(2026, 10, 21, 8, 0, 0, 0, time.UTC)
|
|
||||||
en := Entry{Account: "bob", MessageID: "<a>", SenderName: "bpost", SenderAddress: "no-reply@bpost.example", Summary: "parcel waiting", Mark: MarkMatters, Date: day, DueDate: due}
|
|
||||||
nl := en
|
|
||||||
nl.MessageID, nl.Summary, nl.Date = "<b>", "pakje wacht", day.Add(time.Minute)
|
|
||||||
inbox := Entry{Account: "bob", MessageID: "<c>", Folder: "INBOX", SenderName: "Carol", SenderAddress: "carol@x.example", Summary: "hello", Mark: MarkMatters, Date: day}
|
|
||||||
allMail := inbox
|
|
||||||
allMail.Folder = "All Mail"
|
|
||||||
other := Entry{Account: "bob", MessageID: "<d>", SenderName: "Carol", SenderAddress: "carol@x.example", Summary: "second", Mark: MarkMatters, Date: day}
|
|
||||||
for _, e := range []Entry{en, nl, inbox, allMail, other} {
|
|
||||||
store.AddEntry(ctx, e)
|
|
||||||
}
|
|
||||||
check, _ := recordingCheck(true)
|
|
||||||
tell, headlines, _ := recordingTell("telegram")
|
|
||||||
if err := SendSignal(ctx, store, check, tell, signalNow); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(*headlines) != 1 || (*headlines)[0] != "3 mails matter" {
|
|
||||||
t.Fatalf("1926/AC3: tell headlines = %v, want \"3 mails matter\"", *headlines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user