Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
42541fa
Count a day number from the day the week starts
HynLcc Aug 25, 2026
f7afc4a
Answer how long ago in the unit it was asked for
HynLcc Aug 25, 2026
2670bdb
Put a deleted row in the trash
HynLcc Aug 26, 2026
040188f
Settle the deleted-row trash rejection
HynLcc Aug 26, 2026
be23a75
Compare the minute in a filter on a time of day
HynLcc Aug 26, 2026
d94e27a
Prove the filter is asked the way the product expects it
HynLcc Aug 26, 2026
94f0cd3
Set the filter in the toolbar and look at the table
HynLcc Aug 26, 2026
3b51ee6
Settle the time-of-day filter rejection
HynLcc Aug 26, 2026
7b0180e
Ask every case of v1 too, and let v1 answer for nothing
HynLcc Aug 27, 2026
874f30e
Declare the eleven cases v1 cannot be asked
HynLcc Aug 27, 2026
4c00969
Give the v1 answers a table of their own
HynLcc Aug 27, 2026
31c446f
Stop a background worker's leftovers from deciding a run
HynLcc Aug 27, 2026
7f12423
Give each engine its own job, its own database
HynLcc Aug 27, 2026
af74e9b
Stop asking v1 the eight questions this method cannot put to it
HynLcc Aug 27, 2026
e766107
Ask the field endpoint what the field editor already asks (#133)
HynLcc Sep 3, 2026
eddba46
Copy a table, copy a base, and total what you are linked to (#134)
HynLcc Sep 3, 2026
6af8630
Three summaries over linked rows that gave the wrong answer (#135)
HynLcc Sep 3, 2026
11838ef
A share link, a joined formula, and two records that share a name (#136)
HynLcc Sep 3, 2026
276b6d7
The authority-matrix fixture, and three cases that needed digging (#137)
HynLcc Sep 3, 2026
401754f
Record eleven candidates examined and put down (#138)
HynLcc Sep 3, 2026
577ba76
A view that says both things, and two roles that were not listened to…
HynLcc Sep 3, 2026
0838494
An unplaced column and a doubled address (#140)
HynLcc Sep 3, 2026
b0c20ea
Merge main, drop the v1 reference column, keep the cases
HynLcc Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/e2e-lab.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ jobs:
timeout-minutes: 10
outputs:
execute_plan: ${{ steps.plan.outputs.execute_plan }}
commit_plan: ${{ steps.plan.outputs.commit_plan }}
case_ids: ${{ steps.plan.outputs.case_ids }}
# Per-invocation case filters: the computed-update strategy is fixed at
# app boot, so hybrid-mode cases run in a second vitest invocation.
Expand Down Expand Up @@ -128,6 +129,13 @@ jobs:
E2E_LAB_CASE_FILTER: ${{ inputs.case_filter }}
run: node e2e-lab/scripts/run-plan.mjs

# One job per commit PER ENGINE. Two engines sharing a job was cheaper by one
# bootstrap and wrong in a way that only showed up on reflection: two passes
# against one database means the second engine runs on state the first left,
# and the guarded column is what would have been reading it. Split, each
# engine gets its own containers and its own database from the commit's own
# migrations, and the two run at the same time — the wall clock is one
# engine's, not two.
execute:
name: Run cases (${{ matrix.plan.name }})
needs: resolve_inputs
Expand Down Expand Up @@ -213,6 +221,7 @@ jobs:
export E2E_LAB_CASE_FILTER="${{ needs.resolve_inputs.outputs.sync_case_filter }}"
export E2E_LAB_COMMIT_SHA="${{ matrix.plan.sha }}"
export E2E_LAB_GATING="${{ matrix.plan.gating }}"
export E2E_LAB_ENGINE_LIST="${{ matrix.plan.engine }}"

pnpm -F @teable/backend-ee exec vitest run \
--config ./vitest-e2e-lab.config.ts \
Expand Down Expand Up @@ -305,8 +314,9 @@ jobs:
- name: Build comparison table
env:
E2E_LAB_ARTIFACT_DIR: ${{ github.workspace }}/e2e-lab-artifacts
E2E_LAB_EXECUTE_PLAN: ${{ needs.resolve_inputs.outputs.execute_plan }}
E2E_LAB_EXECUTE_PLAN: ${{ needs.resolve_inputs.outputs.commit_plan }}
E2E_LAB_CASE_FILTER: ${{ inputs.case_filter }}
E2E_LAB_ENGINES: '["v1","v2"]'
E2E_LAB_COMPARISON_PATH: ${{ github.workspace }}/e2e-lab-report/comparison.json
run: node e2e-lab/scripts/build-comparison.mjs

Expand All @@ -328,7 +338,7 @@ jobs:
continue-on-error: true
env:
E2E_LAB_ARTIFACT_DIR: ${{ github.workspace }}/e2e-lab-artifacts
E2E_LAB_EXECUTE_PLAN: ${{ needs.resolve_inputs.outputs.execute_plan }}
E2E_LAB_EXECUTE_PLAN: ${{ needs.resolve_inputs.outputs.commit_plan }}
run: node e2e-lab/scripts/report-teable-track.mjs

# The run's card, built from the same comparison.json the acceptance
Expand Down
15 changes: 13 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,24 @@ picture, [.agents/README.md](.agents/README.md) to add or change a case, and
- Observe through the public API. The database is available for building
fixtures the API cannot express, and only there — reaching for it inside a
checkpoint throws (`framework/fixture-db.ts`).
- Every case guards v2. v1 still answers, so runners prove which engine served
them (`framework/engine.ts`).
- Every case guards v2, and every case is also asked of v1 as a reference.
Runners prove which engine served them (`framework/engine.ts`); a case whose
feature does not exist on v1 declares `skipV1: "why"` instead of failing
there every run.

## Things that look like oversights and are not

Ask before "fixing" any of these:

- **Nothing the v1 column reports can fail a run.** v1 is a reference: the lab
guards v2, which is where fixes land. A v1 cell is evidence to follow up, not
a verdict — partly because reaching v1 at all means unstamping each case's
base, which makes a base no real customer has (theirs predate v2).
- **`skipV1` is declared on the case, never inferred from a failure.** Reading
"v1 said it does not support that" out of an error message fails open: a case
that genuinely breaks, whose error happens to read that way, would be skipped
forever and nobody would learn.

- **A `fixed` case reproducing on an old commit is not red.** That is the world
before the fix. Only the gating column turns a reproduction into a
regression. The table is in `framework/verdict.ts`, one screen.
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,21 @@ The execution skeleton is teable-perf-lab's, proven in production there:
- **Pinning**: refs are resolved to SHAs once, up front; every job checks out
the pinned SHA.
- **One job per commit**: isolated database built from that commit's own
migrations, all selected cases run serially, one JSON payload per case
written _before_ any assertion throws — the payloads are the source of
truth, and failures carry the server's own error body.
migrations, every selected case run once per engine, one JSON payload per
case per engine written _before_ any assertion throws — the payloads are the
source of truth, and failures carry the server's own error body.
- **Fail-closed report**: every planned (case × commit) cell must have exactly
one payload. Missing evidence fails the run; it never renders as an empty
cell someone might read as green.

**Two engines, one of them guarded.** v2 is where fixes land, so that is the
column the run is judged on. v1 is asked the same cases as a reference — what
does the engine our older customers are still on do with this — and nothing it
reports fails a run; it renders as its own table. Reaching v1 needs more than
an environment switch, and a case whose feature v1 does not have declares
`skipV1` rather than failing there every run. Both are explained in
[docs/operations/e2e-lab.md](docs/operations/e2e-lab.md).

What is this repository's own: the verdict model. Each case declares the bug
it reproduces and its believed status (`open` / `fixed`); the run observes
(`absent` / `present` / `error`) and the comparison judges. Known-unfixed bugs
Expand Down
25 changes: 25 additions & 0 deletions cases/base-share/a-share-link-whose-database-is-away.case.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { defineBugCase } from "../../framework/types";

// T6926: a space can be bound to a customer's own database, and that binding can
// be switched off - revoked credentials, a retired connection, a migration part
// way. The share link, the view and the permission are all still correct; there
// is simply nowhere to read from. What came back was an unhandled 500. To
// whoever holds the link - usually somebody outside the company, with no account
// and nobody to ask - a 500 says the product is broken and there is nothing to
// do; a 503 naming an unavailable database says the same page will work later.
export default defineBugCase({
id: "base-share/a-share-link-whose-database-is-away",
title: "A share link whose database is away says so",
runner: "share-view-unready-data-db",
timeoutMs: 180_000,
bug: {
issue: "T6926",
status: "fixed",
sourceCommits: ["bdcca3f24"],
},
config: {
namePrefix: "e2e-lab-share-unready-db",
rowTitle: "a-row-behind-the-link",
encryptedUrlPlaceholder: "not-a-real-connection-string",
},
});
62 changes: 62 additions & 0 deletions cases/base-share/a-share-link-whose-database-is-away.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# base-share/a-share-link-whose-database-is-away

**T6926** — fixed. On the `share-view-unready-data-db` runner.

## What the user sees

Someone opens a share link. The space it belongs to is bound to a database whose
connection has been switched off — revoked credentials, a retired connection, a
migration part way through. The page fails with a 500.

Everything about the share is still correct: the link, the view, the permission.
There is simply nowhere to read the rows from.

The person holding the link is usually outside the company. They have no
account, no way to see anything else, and nobody to ask. A 500 tells them the
product is broken and there is nothing to do about it. A 503 naming an
unavailable database tells them, and anything watching the endpoint, that the
same page will work later.

## Why

Resolving which database a space reads from threw a plain error when the binding
was not usable. Nothing above it recognised that error, so it surfaced as an
unhandled 500 rather than as the outage it describes.

## What the checkpoint asserts

The status **and** the code. 503 alone would be indistinguishable from any other
outage, and being distinguishable is the whole of the fix — so the response must
also call itself `database_connection_unavailable`.

A 200 is called out separately, because a share link that answered normally
while its database was away would be a different and worse problem than the one
this case is about.

## Why the fixture is written with SQL

Binding a space to another database is not part of this observation, and a
connection in the switched-off state is not something a request can ask for.
`fixture-db` writes the two rows; the observation stays on the public share
endpoint.

Before the binding is written, the fixture opens the share link and requires a 200. Without that, a 503 afterwards could just as well mean the share was never
set up — and the case would pass while proving nothing.

The space is created for this case alone. The binding under test is a property
of a space, and this must not touch the one every other case reads from.

## The v1 column

Skipped, for a reason about this harness rather than about the product. The case
makes its own space and base — the binding under test is a property of a space —
and `framework/case-base.ts` unstamps only the base it manages. A base created
inside a runner is born on v2, so a v1 run answers

```
POST /table/{tableId}/view/{viewId}/enable-share was requested of v1
but v2 answered (reason=new_base)
```

which is the harness refusing to fabricate a reference column, not an answer
about v1. Any future runner that creates its own base inherits this.
26 changes: 26 additions & 0 deletions cases/base-share/a-shared-forms-picture.case.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { defineBugCase } from "../../framework/types";

// T6604: where a form's picture lives is stored as a short path, and the address
// a browser can fetch is worked out from it when the form is read. A shared form
// is read through two layers, and both worked it out - the second over the
// first's answer - so what came back was one address with another stuck on the
// front of it, which fetches nothing. The person who opens the link sees a form
// with a broken picture while the same form inside the product looks right,
// because inside it is read through one layer only.
export default defineBugCase({
id: "base-share/a-shared-forms-picture",
title: "A shared form's picture has one address, not two",
runner: "shared-form-cover-url",
timeoutMs: 180_000,
bug: {
issue: "T6604",
status: "fixed",
sourceCommits: ["573e0b70e"],
},
config: {
baseId: "seed-base",
tableNamePrefix: "e2e-lab-form-cover",
rowTitle: "a-row-behind-the-form",
storedPath: "form/e2e-lab-cover-image",
},
});
59 changes: 59 additions & 0 deletions cases/base-share/a-shared-forms-picture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# base-share/a-shared-forms-picture

**T6604** — fixed. On the `shared-form-cover-url` runner.

## What the user sees

A shared form with a broken picture. The same form inside the product looks
right, so nothing is wrong with the picture or the form — only with what the
share link hands out.

The person seeing it is usually outside the company, filling the form in, and has
nothing to compare against.

## Why

Where a form's picture lives is stored as a short path. The address a browser can
fetch is worked out from that path when the form is read.

The shared form is read through two layers, and both worked it out — the second
over the first's answer. What came back was one address with another stuck on the
front of it. Inside the product the same view is read through one layer, which is
why it looks right there.

## What the checkpoint asserts

That the address was built **once**: the cover and the logo each carry exactly one
`http(s)://`, and each ends at the stored path.

Counting addresses rather than comparing against an expected string is
deliberate. What the storage prefix is depends on how the instance is deployed,
and pinning it would make this case about configuration instead of about the
doubling. Ending at the stored path is what says the address still points at the
right thing.

What is counted is the **scheme**, not `http://`. Joining one address onto
another leaves the inner one with a single slash — measured on the fix's parent,
the value is

```
http://127.0.0.1:PORT/api/attachments/read/public/http:/127.0.0.1:PORT/api/attachments/read/public/form/…
```

— so looking for the double slash finds one address in a string that plainly
holds two. The first version of this case did exactly that and passed on both
sides.

Both the cover and the logo are set and both are read, because the fix covers
both and either could regress alone.

## Why the fixture is shaped this way

The stored value must be a **short path**, and the runner refuses an address: an
address is exactly what the fix passes through untouched, so a fixture holding one
would be green on both sides.

Before the checkpoint, the form is read from **inside** the product and its
picture must carry the stored path. That is the control — it says the form and the
stored value are fine, so a doubled address afterwards is about the share path and
not about the fixture.
26 changes: 26 additions & 0 deletions cases/base-share/copy-a-base-whose-tables-share-a-key-name.case.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { defineBugCase } from "../../framework/types";

// T6990: Postgres constraint names are unique per table, not per schema, and
// old bases carry a self-referencing key called fk___id on every table.
// Duplicating a base drops those keys, copies the rows and puts them back - but
// the step that listed them matched on the name and the schema and not on the
// table, so each table's list came back holding the other's rows. The drop ran
// twice for one table, the second found nothing, and the whole copy died on a
// Postgres error naming a constraint that "does not exist". Reported from
// production as an unhandled rejection in the browser, with the base half-made.
export default defineBugCase({
id: "base-share/copy-a-base-whose-tables-share-a-key-name",
title: "A base whose tables share a key name can still be copied",
runner: "same-named-fk-base-duplicate",
timeoutMs: 300_000,
bug: {
issue: "T6990",
status: "fixed",
sourceCommits: ["b913e5014"],
},
config: {
baseNamePrefix: "e2e-lab-same-named-fk",
tableNames: ["the-first-table", "the-second-table"],
rowTitle: "a-row-to-copy",
},
});
44 changes: 44 additions & 0 deletions cases/base-share/copy-a-base-whose-tables-share-a-key-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# base-share/copy-a-base-whose-tables-share-a-key-name

**T6990** — fixed. On the `same-named-fk-base-duplicate` runner.

## What the user sees

Duplicating a base fails. The browser reports an unhandled rejection naming a
Postgres error — a constraint that "does not exist" — and the base is left
half-made. Pressing duplicate again does the same thing, and there is nothing in
the base a person could change to get past it.

## Why

Postgres constraint names are unique per **table**, not per schema. Two tables
in one base can each own a foreign key called `fk___id`, and old bases do: a
self-referencing key on the row id column, from before the naming changed.

Duplicating a base drops those keys, copies the rows, and puts them back. The
step that listed the keys to drop matched on the name and the schema and not on
the table that owns them, so each table's list came back carrying the other
table's rows. The drop then ran the same statement twice for one table; the
second found nothing and raised 42704, and the duplicate died there.

## What the checkpoint asserts

The duplicate succeeds — a refused request throws inside the checkpoint, which
is the report — **and** the copy holds every table. A duplicate that answered
201 while losing a table would be the same interrupted copy behind a success.

## Why the fixture is written with SQL

Nothing a person can do produces `fk___id` any more. It is what an old base has
been carrying since before the naming convention changed, which is also why
nobody hitting this could get out of it from the interface. `fixture-db` is the
only way to build that state; the observation stays on the public duplicate
endpoint.

The fixture then counts, before the checkpoint, how many tables in the schema
carry the name. With only one there is nothing to collide, and the case would
report on nothing.

## Only v2 was repaired

The fix is on the v2 duplicate route's own foreign-key introspection. v1 keeps its untouched legacy helper, so this case says nothing about the older engine either way.
28 changes: 28 additions & 0 deletions cases/field/a-cross-base-conditional-column-keeps-its-base.case.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { defineBugCase } from "../../framework/types";

// T7064: a conditional column reading a table in another base needs to record
// which base that is. It was dropped on the way into storage, so reopening the
// column's settings found a foreign table it could not place and drew it as a
// table the person has no permission to see. The values kept arriving - only
// the settings could no longer describe themselves, which costs the ability to
// change the column at all.
export default defineBugCase({
id: "field/a-cross-base-conditional-column-keeps-its-base",
title: "A conditional column reading another base still names that base",
runner: "cross-base-conditional-base-id",
timeoutMs: 300_000,
bug: {
issue: "T7064",
status: "fixed",
sourceCommits: ["e552c5e88"],
},
config: {
namePrefix: "e2e-lab-cross-base-conditional",
matchedCategory: "hardware",
sourceRows: [
{ category: "hardware", amount: 100 },
{ category: "hardware", amount: 50 },
{ category: "software", amount: 70 },
],
},
});
Loading